Merge pull request #9636 from opf/feature/38723-notification-settings-frontend

Feature/38723 notification settings frontend
pull/9687/head
Oliver Günther 3 years ago committed by GitHub
commit 6bf3475348
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      app/controllers/my_controller.rb
  2. 1
      app/views/layouts/angular.html.erb
  3. 43
      config/locales/js-en.yml
  4. 6
      frontend/src/app/core/current-project/current-project.service.ts
  5. 3
      frontend/src/app/features/user-preferences/notifications-settings/inline-create/notification-setting-inline-create.component.sass
  6. 25
      frontend/src/app/features/user-preferences/notifications-settings/inline-create/notification-setting-inline-create.component.ts
  7. 119
      frontend/src/app/features/user-preferences/notifications-settings/page/notifications-settings-page.component.html
  8. 167
      frontend/src/app/features/user-preferences/notifications-settings/page/notifications-settings-page.component.ts
  9. 116
      frontend/src/app/features/user-preferences/notifications-settings/row/notification-setting-row.component.html
  10. 80
      frontend/src/app/features/user-preferences/notifications-settings/row/notification-setting-row.component.ts
  11. 255
      frontend/src/app/features/user-preferences/notifications-settings/table/notification-settings-table.component.html
  12. 8
      frontend/src/app/features/user-preferences/notifications-settings/table/notification-settings-table.component.sass
  13. 82
      frontend/src/app/features/user-preferences/notifications-settings/table/notification-settings-table.component.ts
  14. 13
      frontend/src/app/features/user-preferences/notifications-settings/toolbar/notifications-settings-toolbar.component.html
  15. 1
      frontend/src/app/features/user-preferences/notifications-settings/toolbar/notifications-settings-toolbar.component.ts
  16. 14
      frontend/src/app/features/user-preferences/state/user-preferences.query.ts
  17. 2
      frontend/src/app/features/user-preferences/user-preferences.module.ts
  18. 3
      frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts
  19. 22
      frontend/src/app/shared/components/dynamic-forms/components/dynamic-field-wrapper/dynamic-field-wrapper.component.html
  20. 45
      frontend/src/app/shared/components/forms/checkbox-field/checkbox-field.component.html
  21. 80
      frontend/src/app/shared/components/forms/checkbox-field/checkbox-field.component.ts
  22. 34
      frontend/src/app/shared/components/forms/checkbox-field/checkbox-field.sass
  23. 2
      frontend/src/app/shared/components/forms/form-field/form-field.component.ts
  24. 21
      frontend/src/app/shared/components/forms/form-field/form-field.sass
  25. 1
      frontend/src/app/shared/components/forms/form.sass
  26. 1
      frontend/src/app/shared/components/forms/index.sass
  27. 6
      frontend/src/app/shared/components/table/scrollable-table.sass
  28. 18
      frontend/src/app/shared/components/table/table.sass
  29. 4
      frontend/src/app/shared/shared.module.ts
  30. 2
      frontend/src/global_styles/common/openproject-common.module.sass
  31. 7
      frontend/src/global_styles/content/_headings.sass
  32. 65
      spec/features/notifications/digest_mail_spec.rb
  33. 76
      spec/features/users/notifications/shared_examples.rb
  34. 60
      spec/support/pages/notifications/settings.rb

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

@ -28,6 +28,7 @@ See COPYRIGHT and LICENSE files for more details.
++#%> ++#%>
<%= content_for :header_tags do %> <%= content_for :header_tags do %>
<% html_title(*local_assigns[:page_title]) if local_assigns[:page_title].present? %>
<%= nonced_javascript_tag do %> <%= nonced_javascript_tag do %>
<%= include_gon(need_tag: false) -%> <%= include_gon(need_tag: false) -%>
<% end %> <% end %>

@ -563,10 +563,6 @@ en:
title: "Notifications" title: "Notifications"
channel: "Channel" channel: "Channel"
no_unread: "No unread notifications" no_unread: "No unread notifications"
channels:
in_app: "In-app"
mail: "Email"
mail_digest: "Daily email summary"
reasons: reasons:
mentioned: 'mentioned' mentioned: 'mentioned'
watched: 'watched' watched: 'watched'
@ -583,20 +579,33 @@ en:
text_update_date: "%{date} by" text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed." total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
settings: settings:
default_all_projects: 'Default for all projects'
involved: 'I am involved'
mentioned: 'I was mentioned'
watched: 'I am watching'
work_package_commented: 'Work package commented'
work_package_created: 'Work package created'
work_package_processed: 'Work package status changed'
work_package_prioritized: 'Work package priority changed'
work_package_scheduled: 'Work package scheduled'
all: 'All events'
add: 'Add setting for project'
already_selected: 'This project is already selected'
remove_projects: 'Remove notifications for projects'
title: "Notification settings" title: "Notification settings"
notify_me: "Notify me"
reasons:
mentioned:
title: 'I am @mentioned'
description: 'Receive a notification every time someone mentions me anywhere'
involved:
title: 'Assigned to me or accountable'
description: 'Receive notifications for all activities on work packages for which I am assignee or accountable.'
watched: 'Updates on watched items'
work_package_commented: 'All new comments'
work_package_created: 'New work packages'
work_package_processed: 'All status changes'
work_package_prioritized: 'all priority changes'
work_package_scheduled: 'All date changes'
global:
immediately:
title: 'Notify me immediately'
description: 'These settings apply to all projects. You can create project-specific exceptions below.'
delayed:
title: 'Also notify me for'
description: 'Receive notifications for these activities on work packages in all projects:'
project_specific:
title: 'Project-specific notification settings'
description: 'These project-specific settings override default settings above'
add: 'Add setting for project'
already_selected: 'This project is already selected'
password_confirmation: password_confirmation:
field_description: 'You need to enter your account password to confirm this change.' field_description: 'You need to enter your account password to confirm this change.'

@ -34,8 +34,10 @@ import { APIV3Service } from 'core-app/core/apiv3/api-v3.service';
export class CurrentProjectService { export class CurrentProjectService {
private current:{ id:string, identifier:string, name:string }; private current:{ id:string, identifier:string, name:string };
constructor(private PathHelper:PathHelperService, constructor(
private apiV3Service:APIV3Service) { private PathHelper:PathHelperService,
private apiV3Service:APIV3Service,
) {
this.detect(); this.detect();
} }

@ -5,12 +5,10 @@ import {
Input, Input,
Output, Output,
} from '@angular/core'; } from '@angular/core';
import { FormArray } from '@angular/forms';
import { I18nService } from 'core-app/core/i18n/i18n.service'; import { I18nService } from 'core-app/core/i18n/i18n.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { import { map } from 'rxjs/operators';
map,
withLatestFrom,
} from 'rxjs/operators';
import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; import { APIV3Service } from 'core-app/core/apiv3/api-v3.service';
import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder'; import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
import { HalSourceLink } from 'core-app/features/hal/resources/hal-resource'; import { HalSourceLink } from 'core-app/features/hal/resources/hal-resource';
@ -25,20 +23,23 @@ export interface NotificationSettingProjectOption {
@Component({ @Component({
selector: 'op-notification-setting-inline-create', selector: 'op-notification-setting-inline-create',
templateUrl: './notification-setting-inline-create.component.html', templateUrl: './notification-setting-inline-create.component.html',
styleUrls: ['./notification-setting-inline-create.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class NotificationSettingInlineCreateComponent { export class NotificationSettingInlineCreateComponent {
@Input() userId:string; @Input() userId:string;
@Input() settings:FormArray;
@Output() selected = new EventEmitter<HalSourceLink>(); @Output() selected = new EventEmitter<HalSourceLink>();
/** Active inline-create mode */ /** Active inline-create mode */
active = false; active = false;
text = { text = {
add_setting: this.I18n.t('js.notifications.settings.add'), add_setting: this.I18n.t('js.notifications.settings.project_specific.add'),
please_select: this.I18n.t('js.placeholders.selection'), please_select: this.I18n.t('js.placeholders.selection'),
already_selected: this.I18n.t('js.notifications.settings.already_selected'), already_selected: this.I18n.t('js.notifications.settings.project_specific.already_selected'),
}; };
public autocompleterOptions = { public autocompleterOptions = {
@ -50,7 +51,6 @@ export class NotificationSettingInlineCreateComponent {
constructor( constructor(
private I18n:I18nService, private I18n:I18nService,
private apiV3Service:APIV3Service, private apiV3Service:APIV3Service,
private storeService:UserPreferencesService,
) { ) {
} }
@ -73,12 +73,13 @@ export class NotificationSettingInlineCreateComponent {
.filtered(filters) .filtered(filters)
.get() .get()
.pipe( .pipe(
withLatestFrom(this.storeService.query.selectedProjects$), map((collection) => collection.elements.map((project) => ({
map(([collection, selected]) => collection.elements.map( href: project.href || '',
(project) => ( name: project.name,
{ href: project.href || '', name: project.name, disabled: selected.has(project.href) } disabled: !!this.settings.controls.find(
(projectSetting) => (projectSetting.get('project')!.value as NotificationSettingProjectOption).href === project.href,
), ),
)), }))),
); );
} }
} }

@ -1,16 +1,117 @@
<op-notifications-settings-toolbar></op-notifications-settings-toolbar> <op-notifications-settings-toolbar></op-notifications-settings-toolbar>
<op-notification-settings-table <form
[formGroup]="form"
(ngSubmit)="saveChanges()"
class="op-form"
>
<h5>{{ text.notifyImmediately.title }}</h5>
<p>{{ text.notifyImmediately.description }}</p>
<op-checkbox-field [label]="text.mentioned.title">
<input
disabled
checked
type="checkbox"
slot="input"
/>
<p slot="description">{{ text.mentioned.description }}</p>
</op-checkbox-field>
<op-checkbox-field
[label]="text.involved.title"
[control]="form.get('involved')"
>
<input
slot="input"
type="checkbox"
formControlName="involved"
data-qa-global-notification-type="involved"
/>
<p slot="description">{{ text.mentioned.title }}</p>
</op-checkbox-field>
<h5>{{ text.alsoNotifyFor.title }}</h5>
<p>{{ text.alsoNotifyFor.description }}</p>
<op-checkbox-field>
<input
disabled
checked
type="checkbox"
slot="input"
/>
<p slot="description">{{ text.watched }}</p>
</op-checkbox-field>
<op-checkbox-field [control]="form.get('workPackageCreated')">
<input
slot="input"
type="checkbox"
formControlName="workPackageCreated"
data-qa-global-notification-type="work_package_created"
/>
<p slot="description">{{ text.work_package_created }}</p>
</op-checkbox-field>
<op-checkbox-field [control]="form.get('workPackageProcessed')">
<input
slot="input"
type="checkbox"
formControlName="workPackageProcessed"
data-qa-global-notification-type="work_package_processed"
/>
<p slot="description">{{ text.work_package_processed }}</p>
</op-checkbox-field>
<op-checkbox-field [control]="form.get('workPackageScheduled')">
<input
slot="input"
type="checkbox"
formControlName="workPackageScheduled"
data-qa-global-notification-type="work_package_scheduled"
/>
<p slot="description">{{ text.work_package_scheduled }}</p>
</op-checkbox-field>
<op-checkbox-field [control]="form.get('workPackagePrioritized')">
<input
slot="input"
type="checkbox"
formControlName="workPackagePrioritized"
data-qa-global-notification-type="work_package_prioritized"
/>
<p slot="description">{{ text.work_package_prioritized }}</p>
</op-checkbox-field>
<op-checkbox-field [control]="form.get('workPackageCommented')">
<input
slot="input"
type="checkbox"
formControlName="workPackageCommented"
data-qa-global-notification-type="work_package_commented"
/>
<p slot="description">{{ text.work_package_commented }}</p>
</op-checkbox-field>
<hr />
<h5>Project-specific notification settings</h5>
<p>These project-specific settings override default settings above</p>
<op-notification-settings-table
*ngIf="userId" *ngIf="userId"
[userId]="userId" [userId]="userId"
></op-notification-settings-table> [settings]="form.controls.projectSettings"
formArrayName="projectSettings"
class="op-notification-settings-page--table"
></op-notification-settings-table>
<div class="generic-table--action-buttons"> <div class="generic-table--action-buttons">
<button <button
class="button -highlight" class="button -highlight"
[textContent]="text.save" [textContent]="text.save"
(click)="saveChanges()" type="submit"
> ></button>
</button> </div>
</div> </form>

@ -1,36 +1,101 @@
import { import {
ChangeDetectionStrategy, Component, Input, OnInit, ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnInit,
} from '@angular/core'; } from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service'; import {
import { CurrentUserService } from 'core-app/core/current-user/current-user.service'; FormGroup,
FormArray,
FormControl,
} from '@angular/forms';
import { take } from 'rxjs/internal/operators/take'; import { take } from 'rxjs/internal/operators/take';
import { UIRouterGlobals } from '@uirouter/core'; import { UIRouterGlobals } from '@uirouter/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { UserPreferencesService } from 'core-app/features/user-preferences/state/user-preferences.service'; import { UserPreferencesService } from 'core-app/features/user-preferences/state/user-preferences.service';
import { UserPreferencesQuery } from 'core-app/features/user-preferences/state/user-preferences.query'; import { NotificationSetting } from 'core-app/features/user-preferences/state/notification-setting.model';
export const myNotificationsPageComponentSelector = 'op-notifications-page'; export const myNotificationsPageComponentSelector = 'op-notifications-page';
interface INotificationSettingsValue {
involved:boolean;
workPackageCreated:boolean;
workPackageProcessed:boolean;
workPackageScheduled:boolean;
workPackagePrioritized:boolean;
workPackageCommented:boolean;
}
interface IProjectNotificationSettingsValue extends INotificationSettingsValue {
project:{
title:string;
href:string;
};
}
interface IFullNotificationSettingsValue extends INotificationSettingsValue {
projectSettings:IProjectNotificationSettingsValue[];
}
@Component({ @Component({
selector: myNotificationsPageComponentSelector, selector: myNotificationsPageComponentSelector,
templateUrl: './notifications-settings-page.component.html', templateUrl: './notifications-settings-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class NotificationsSettingsPageComponent implements OnInit { export class NotificationsSettingsPageComponent extends UntilDestroyedMixin implements OnInit {
@Input() userId:string; @Input() userId:string;
public form = new FormGroup({
involved: new FormControl(false),
workPackageCreated: new FormControl(false),
workPackageProcessed: new FormControl(false),
workPackageScheduled: new FormControl(false),
workPackagePrioritized: new FormControl(false),
workPackageCommented: new FormControl(false),
projectSettings: new FormArray([]),
});
text = { text = {
notifyImmediately: {
title: this.I18n.t('js.notifications.settings.global.immediately.title'),
description: this.I18n.t('js.notifications.settings.global.immediately.description'),
},
alsoNotifyFor: {
title: this.I18n.t('js.notifications.settings.global.delayed.title'),
description: this.I18n.t('js.notifications.settings.global.delayed.description'),
},
mentioned: {
title: this.I18n.t('js.notifications.settings.reasons.mentioned.title'),
description: this.I18n.t('js.notifications.settings.reasons.mentioned.description'),
},
involved: {
title: this.I18n.t('js.notifications.settings.reasons.involved.title'),
description: this.I18n.t('js.notifications.settings.reasons.involved.description'),
},
watched: this.I18n.t('js.notifications.settings.reasons.watched'),
work_package_commented: this.I18n.t('js.notifications.settings.reasons.work_package_commented'),
work_package_created: this.I18n.t('js.notifications.settings.reasons.work_package_created'),
work_package_processed: this.I18n.t('js.notifications.settings.reasons.work_package_processed'),
work_package_prioritized: this.I18n.t('js.notifications.settings.reasons.work_package_prioritized'),
work_package_scheduled: this.I18n.t('js.notifications.settings.reasons.work_package_scheduled'),
save: this.I18n.t('js.button_save'), save: this.I18n.t('js.button_save'),
email: this.I18n.t('js.notifications.email'), projectSpecific: {
inApp: this.I18n.t('js.notifications.in_app'), title: this.I18n.t('js.notifications.settings.project_specific.title'),
default_all_projects: this.I18n.t('js.notifications.settings.default_all_projects'), description: this.I18n.t('js.notifications.settings.project_specific.description'),
},
}; };
constructor( constructor(
private changeDetectorRef:ChangeDetectorRef,
private I18n:I18nService, private I18n:I18nService,
private storeService:UserPreferencesService, private storeService:UserPreferencesService,
private currentUserService:CurrentUserService, private currentUserService:CurrentUserService,
private uiRouterGlobals:UIRouterGlobals, private uiRouterGlobals:UIRouterGlobals,
) { ) {
super();
} }
ngOnInit():void { ngOnInit():void {
@ -43,10 +108,92 @@ export class NotificationsSettingsPageComponent implements OnInit {
this.userId = this.userId || user.id!; this.userId = this.userId || user.id!;
this.storeService.get(this.userId); this.storeService.get(this.userId);
}); });
this.storeService.query.notificationsForGlobal$
.pipe(this.untilDestroyed())
.subscribe((settings) => {
if (!settings) {
return;
}
this.form.get('involved')?.setValue(settings.involved);
this.form.get('workPackageCreated')?.setValue(settings.workPackageCreated);
this.form.get('workPackageProcessed')?.setValue(settings.workPackageProcessed);
this.form.get('workPackageScheduled')?.setValue(settings.workPackageScheduled);
this.form.get('workPackagePrioritized')?.setValue(settings.workPackagePrioritized);
this.form.get('workPackageCommented')?.setValue(settings.workPackageCommented);
});
this.storeService.query.projectNotifications$
.pipe(this.untilDestroyed())
.subscribe((settings) => {
if (!settings) {
return;
}
const projectSettings = new FormArray([]);
projectSettings.clear();
settings
.sort(
(a, b):number => a._links.project.title!.localeCompare(b._links.project.title!),
)
.forEach((setting) => projectSettings.push(new FormGroup({
project: new FormControl(setting._links.project),
involved: new FormControl(setting.involved),
workPackageCreated: new FormControl(setting.workPackageCreated),
workPackageProcessed: new FormControl(setting.workPackageProcessed),
workPackageScheduled: new FormControl(setting.workPackageScheduled),
workPackagePrioritized: new FormControl(setting.workPackagePrioritized),
workPackageCommented: new FormControl(setting.workPackageCommented),
})));
this.form.setControl('projectSettings', projectSettings);
this.changeDetectorRef.detectChanges();
});
} }
public saveChanges():void { public saveChanges():void {
const prefs = this.storeService.query.getValue(); const prefs = this.storeService.store.getValue();
this.storeService.update(this.userId, prefs); const notificationSettings = (this.form.value as IFullNotificationSettingsValue);
const globalPrefs:NotificationSetting = {
_links: { project: { href: null } },
channel: 'in_app',
watched: true,
mentioned: true,
involved: notificationSettings.involved,
workPackageCreated: notificationSettings.workPackageCreated,
workPackageProcessed: notificationSettings.workPackageProcessed,
workPackageScheduled: notificationSettings.workPackageScheduled,
workPackagePrioritized: notificationSettings.workPackagePrioritized,
workPackageCommented: notificationSettings.workPackageCommented,
all: false,
};
const projectPrefs:NotificationSetting[] = notificationSettings.projectSettings.map((settings) => ({
_links: { project: { href: settings.project.href } },
channel: 'in_app',
watched: true,
mentioned: true,
involved: settings.involved,
workPackageCreated: settings.workPackageCreated,
workPackageProcessed: settings.workPackageProcessed,
workPackageScheduled: settings.workPackageScheduled,
workPackagePrioritized: settings.workPackagePrioritized,
workPackageCommented: settings.workPackageCommented,
all: false,
}));
this.storeService.update(this.userId, {
...prefs,
notifications: [
globalPrefs,
...projectPrefs,
].reduce((total, next) => [
...total,
next,
{ ...next, channel: 'mail' },
{ ...next, channel: 'mail_digest' },
], []),
});
} }
} }

@ -1,116 +0,0 @@
<td
*ngIf="first"
[attr.rowspan]="count"
>
<span
*ngIf="setting._links.project.href; else defaultTitle"
[textContent]="setting._links.project.title"
></span>
<ng-template #defaultTitle>
<em [textContent]="text.default_all_projects"></em>
</ng-template>
</td>
<td [textContent]="text.channel(setting.channel)">
</td>
<td>
<input
type="checkbox"
[checked]="setting.involved || setting.all"
[disabled]="setting.all"
(change)="update({ involved: $event.target.checked })"
data-qa-notification-type="involved"
[attr.aria-label]="text.involved_header"
/>
</td>
<td>
<input
type="checkbox"
[checked]="setting.mentioned || setting.all"
[disabled]="setting.all"
(change)="update({ mentioned: $event.target.checked })"
data-qa-notification-type="mentioned"
[attr.aria-label]="text.mentioned_header"
/>
</td>
<td>
<input
type="checkbox"
[checked]="setting.watched || setting.all"
[disabled]="setting.all"
(change)="update({ watched: $event.target.checked })"
data-qa-notification-type="watched"
[attr.aria-label]="text.watched_header"
/>
</td>
<td>
<input
type="checkbox"
[checked]="setting.workPackageCreated || setting.all"
[disabled]="setting.all"
(change)="update({ workPackageCreated: $event.target.checked })"
data-qa-notification-type="work_package_created"
[attr.aria-label]="text.work_package_created_header"
/>
</td>
<td>
<input
type="checkbox"
[checked]="setting.workPackageCommented || setting.all"
[disabled]="setting.all"
(change)="update({ workPackageCommented: $event.target.checked })"
data-qa-notification-type="work_package_commented"
[attr.aria-label]="text.work_package_commented_header"
/>
</td>
<td>
<input
type="checkbox"
[checked]="setting.workPackageProcessed || setting.all"
[disabled]="setting.all"
(change)="update({ workPackageProcessed: $event.target.checked })"
data-qa-notification-type="work_package_processed"
[attr.aria-label]="text.work_package_processed_header"
/>
</td>
<td>
<input
type="checkbox"
[checked]="setting.workPackagePrioritized || setting.all"
[disabled]="setting.all"
(change)="update({ workPackagePrioritized: $event.target.checked })"
data-qa-notification-type="work_package_prioritized"
[attr.aria-label]="text.work_package_prioritized_header"
/>
</td>
<td>
<input
type="checkbox"
[checked]="setting.workPackageScheduled || setting.all"
[disabled]="setting.all"
(change)="update({ workPackageScheduled: $event.target.checked })"
data-qa-notification-type="work_package_scheduled"
[attr.aria-label]="text.work_package_scheduled_header"
/>
</td>
<td>
<input
type="checkbox"
[checked]="setting.all"
(change)="update({ all: $event.target.checked })"
data-qa-notification-type="all"
[attr.aria-label]="text.any_event_header"
/>
</td>
<td
*ngIf="first"
[attr.rowspan]="count"
class="buttons"
>
<button
*ngIf="!global"
class="op-link"
(click)="remove()"
>
<op-icon icon-classes="icon-remove icon-no-color"></op-icon>
</button>
</td>

@ -1,80 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnInit,
} from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { arrayUpdate } from '@datorama/akita';
import { NotificationSetting } from 'core-app/features/user-preferences/state/notification-setting.model';
import { UserPreferencesService } from 'core-app/features/user-preferences/state/user-preferences.service';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: '[op-notification-setting-row]',
templateUrl: './notification-setting-row.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NotificationSettingRowComponent implements OnInit {
@Input() first = false;
@Input() count:number;
@Input() setting:NotificationSetting;
/** Whether this setting is global */
global = false;
text = {
title: this.I18n.t('js.notifications.settings.title'),
save: this.I18n.t('js.button_save'),
email: this.I18n.t('js.notifications.email'),
inApp: this.I18n.t('js.notifications.in_app'),
remove_all: this.I18n.t('js.notifications.settings.remove_all'),
involved_header: this.I18n.t('js.notifications.settings.involved'),
mentioned_header: this.I18n.t('js.notifications.settings.mentioned'),
watched_header: this.I18n.t('js.notifications.settings.watched'),
work_package_commented_header: this.I18n.t('js.notifications.settings.work_package_commented'),
work_package_created_header: this.I18n.t('js.notifications.settings.work_package_created'),
work_package_processed_header: this.I18n.t('js.notifications.settings.work_package_processed'),
work_package_prioritized_header: this.I18n.t('js.notifications.settings.work_package_prioritized'),
work_package_scheduled_header: this.I18n.t('js.notifications.settings.work_package_scheduled'),
any_event_header: this.I18n.t('js.notifications.settings.all'),
default_all_projects: this.I18n.t('js.notifications.settings.default_all_projects'),
add_setting: this.I18n.t('js.notifications.settings.add'),
channel: (channel:string):string => this.I18n.t(`js.notifications.channels.${channel}`),
};
constructor(
private I18n:I18nService,
private storeService:UserPreferencesService,
) {
}
ngOnInit() {
this.global = this.setting._links.project.href === null;
}
remove():void {
this.storeService.store.update(
({ notifications }) => ({
notifications: notifications.filter((notification) => notification._links.project.href !== this.setting._links.project.href),
}),
);
}
update(delta:Partial<NotificationSetting>) {
this.storeService.store.update(
({ notifications }) => ({
notifications: arrayUpdate(
notifications, this.matcherFn.bind(this), delta,
),
}),
);
}
private matcherFn(notification:NotificationSetting) {
return notification._links.project.href === this.setting._links.project.href
&& notification.channel === this.setting.channel;
}
}

@ -1,125 +1,142 @@
<div class="generic-table--container form--section"> <div class="op-scrollable-table">
<div class="generic-table--results-container"> <table
<table class="generic-table"> class="op-table"
<colgroup> *ngIf="settings.length > 0"
<col> >
<col opHighlightCol> <thead>
<col opHighlightCol>
<col opHighlightCol>
<col opHighlightCol>
<col opHighlightCol>
<col opHighlightCol>
<col opHighlightCol>
<col opHighlightCol>
<col opHighlightCol>
<col opHighlightCol>
</colgroup>
<thead>
<tr> <tr>
<th> <th class="op-table--cell op-table--cell_heading">
<div class="generic-table--empty-header"></div> {{ text.notify_me }}
</th> </th>
<th> <ng-container *ngFor="let item of settings.controls">
<div class="generic-table--sort-header-outer"> <th class="op-table--cell op-table--cell_heading">
<div class="generic-table--sort-header"> <a
<span [textContent]="text.channel_header"></span> class="op-link"
</div> [href]="projectLink(item.controls.project.value.href)"
</div> >{{ item.controls.project.value.title }}</a>
</th> </th>
<th> </ng-container>
<div class="generic-table--sort-header-outer"> </tr>
<div class="generic-table--sort-header"> </thead>
<span [textContent]="text.involved_header"></span> <tbody>
</div> <tr>
</div> <th class="op-table--cell op-table--cell_soft-heading">
</th> <h5>{{ text.mentioned_header.title }}</h5>
<th> <p>{{ text.mentioned_header.description }}</p>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.mentioned_header"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.watched_header"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.work_package_created_header"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.work_package_commented_header"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.work_package_processed_header"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.work_package_prioritized_header"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.work_package_scheduled_header"></span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span [textContent]="text.any_event_header"></span>
</div>
</div>
</th> </th>
<th> <ng-container *ngFor="let item of settings.controls">
<div class="generic-table--empty-header"></div> <td class="op-table--cell"><input type="checkbox" disabled checked /></td>
</ng-container>
</tr>
<tr>
<th class="op-table--cell op-table--cell_soft-heading">
<h5>{{ text.involved_header.title }}</h5>
<p>{{ text.involved_header.description }}</p>
</th> </th>
<ng-container *ngFor="let item of settings.controls">
<td class="op-table--cell" [formGroup]="item">
<input
type="checkbox"
formControlName="involved"
data-qa-project-notification-type="involved"
[attr.data-qa-project]="item.controls.project.value.title"
/>
</td>
</ng-container>
</tr>
<tr>
<th class="op-table--cell op-table--cell_soft-heading">{{ text.watched_header }}</th>
<ng-container *ngFor="let item of settings.controls">
<td class="op-table--cell"><input type="checkbox" disabled checked /></td>
</ng-container>
</tr>
<tr>
<th class="op-table--cell op-table--cell_soft-heading">{{ text.work_package_created_header }}</th>
<ng-container *ngFor="let item of settings.controls">
<td class="op-table--cell" [formGroup]="item">
<input
type="checkbox"
formControlName="workPackageCreated"
data-qa-project-notification-type="work_package_created"
[attr.data-qa-project]="item.controls.project.value.title"
/>
</td>
</ng-container>
</tr>
<tr>
<th class="op-table--cell op-table--cell_soft-heading">{{ text.work_package_processed_header }}</th>
<ng-container *ngFor="let item of settings.controls">
<td class="op-table--cell" [formGroup]="item">
<input
type="checkbox"
formControlName="workPackageProcessed"
data-qa-project-notification-type="work_package_processed"
[attr.data-qa-project]="item.controls.project.value.title"
/>
</td>
</ng-container>
</tr>
<tr>
<th class="op-table--cell op-table--cell_soft-heading">{{ text.work_package_scheduled_header }}</th>
<ng-container *ngFor="let item of settings.controls">
<td class="op-table--cell" [formGroup]="item">
<input
type="checkbox"
formControlName="workPackageScheduled"
data-qa-project-notification-type="work_package_scheduled"
[attr.data-qa-project]="item.controls.project.value.title"
/>
</td>
</ng-container>
</tr> </tr>
</thead> <tr>
<tbody> <th class="op-table--cell op-table--cell_soft-heading">{{ text.work_package_prioritized_header }}</th>
<ng-container *ngFor="let item of (groupedNotificationSettings$ | async) | keyvalue: projectOrder"> <ng-container *ngFor="let item of settings.controls">
<ng-container *ngFor="let setting of item.value; let first = first; let last = last"> <td class="op-table--cell" [formGroup]="item">
<tr <input
class="-no-highlighting" type="checkbox"
op-notification-setting-row formControlName="workPackagePrioritized"
[attr.data-qa-notification-project]="item.key" data-qa-project-notification-type="work_package_prioritized"
[attr.data-qa-notification-channel]="setting.channel" [attr.data-qa-project]="item.controls.project.value.title"
[count]="item.value.length" />
[first]="first" </td>
[setting]="setting" </ng-container>
> </tr>
</tr> <tr>
<tr *ngIf="last" <th class="op-table--cell op-table--cell_soft-heading">{{ text.work_package_commented_header }}</th>
class="op-notifications-settings-table--spacer"> <ng-container *ngFor="let item of settings.controls">
<td colspan="7"></td> <td class="op-table--cell" [formGroup]="item">
</tr> <input
type="checkbox"
formControlName="workPackageCommented"
data-qa-project-notification-type="work_package_commented"
[attr.data-qa-project]="item.controls.project.value.title"
/>
</td>
</ng-container> </ng-container>
</ng-container> </tr>
</tbody> <tr>
</table> <th class="op-table--cell op-table--cell_soft-heading"></th>
<op-notification-setting-inline-create <ng-container *ngFor="let item of settings.controls; let index = index">
*ngIf="userId" <td class="op-table--cell">
[userId]="userId" <button
(selected)="addRow($event)" type="button"
data-qa-selector="notification-setting-inline-create" class="op-link"
></op-notification-setting-inline-create> (click)="removeProjectSettings(index)"
</div> >
</div> Remove Project Settings
</button>
</td>
</ng-container>
</tr>
</tbody>
</table>
</div>
<op-notification-setting-inline-create
*ngIf="userId"
[userId]="userId"
[settings]="settings"
(selected)="addProjectSettings($event)"
data-qa-selector="notification-setting-inline-create"
></op-notification-setting-inline-create>

@ -1,6 +1,2 @@
.op-notifications-settings-table .op-table
&--spacer margin-bottom: 1rem
// The default table styles are a mess
td
padding: 0 !important

@ -1,19 +1,16 @@
// noinspection ES6UnusedImports // noinspection ES6UnusedImports
import { import {
ChangeDetectionStrategy,
Component, Component,
ChangeDetectionStrategy,
Input, Input,
} from '@angular/core'; } from '@angular/core';
import { KeyValue } from '@angular/common'; import { FormArray, FormGroup, FormControl } from '@angular/forms';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import { I18nService } from 'core-app/core/i18n/i18n.service'; import { I18nService } from 'core-app/core/i18n/i18n.service';
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
import { UserPreferencesService } from 'core-app/features/user-preferences/state/user-preferences.service'; import { UserPreferencesService } from 'core-app/features/user-preferences/state/user-preferences.service';
import { HalSourceLink } from 'core-app/features/hal/resources/hal-resource'; import { HalSourceLink } from 'core-app/features/hal/resources/hal-resource';
import {
buildNotificationSetting,
NotificationSetting,
} from 'core-app/features/user-preferences/state/notification-setting.model';
import { arrayAdd } from '@datorama/akita';
@Component({ @Component({
selector: 'op-notification-settings-table', selector: 'op-notification-settings-table',
@ -24,52 +21,49 @@ import { arrayAdd } from '@datorama/akita';
export class NotificationSettingsTableComponent { export class NotificationSettingsTableComponent {
@Input() userId:string; @Input() userId:string;
groupedNotificationSettings$ = this.storeService.query.notificationsGroupedByProject$; @Input() settings:FormArray;
text = { text = {
notify_me: this.I18n.t('js.notifications.settings.notify_me'),
save: this.I18n.t('js.button_save'), save: this.I18n.t('js.button_save'),
involved_header: this.I18n.t('js.notifications.settings.involved'), mentioned_header: {
channel_header: this.I18n.t('js.notifications.channel'), title: this.I18n.t('js.notifications.settings.reasons.mentioned.title'),
mentioned_header: this.I18n.t('js.notifications.settings.mentioned'), description: this.I18n.t('js.notifications.settings.reasons.mentioned.description'),
watched_header: this.I18n.t('js.notifications.settings.watched'), },
work_package_commented_header: this.I18n.t('js.notifications.settings.work_package_commented'), involved_header: {
work_package_created_header: this.I18n.t('js.notifications.settings.work_package_created'), title: this.I18n.t('js.notifications.settings.reasons.involved.title'),
work_package_processed_header: this.I18n.t('js.notifications.settings.work_package_processed'), description: this.I18n.t('js.notifications.settings.reasons.involved.description'),
work_package_prioritized_header: this.I18n.t('js.notifications.settings.work_package_prioritized'), },
work_package_scheduled_header: this.I18n.t('js.notifications.settings.work_package_scheduled'), watched_header: this.I18n.t('js.notifications.settings.reasons.watched'),
any_event_header: this.I18n.t('js.notifications.settings.all'), work_package_commented_header: this.I18n.t('js.notifications.settings.reasons.work_package_commented'),
default_all_projects: this.I18n.t('js.notifications.settings.default_all_projects'), work_package_created_header: this.I18n.t('js.notifications.settings.reasons.work_package_created'),
}; work_package_processed_header: this.I18n.t('js.notifications.settings.reasons.work_package_processed'),
work_package_prioritized_header: this.I18n.t('js.notifications.settings.reasons.work_package_prioritized'),
projectOrder = (a:KeyValue<string, unknown>, b:KeyValue<string, unknown>):number => { work_package_scheduled_header: this.I18n.t('js.notifications.settings.reasons.work_package_scheduled'),
if (a.key === 'global') {
return -1;
}
if (b.key === 'global') {
return 1;
}
return a.key.localeCompare(b.key);
}; };
constructor( constructor(
private I18n:I18nService, private I18n:I18nService,
private storeService:UserPreferencesService, private pathHelper:PathHelperService,
) { ) {}
projectLink(href:string) {
return this.pathHelper.projectPath(idFromLink(href));
} }
addRow(project:HalSourceLink) { addProjectSettings(project:HalSourceLink):void {
const added:NotificationSetting[] = [ this.settings.push(new FormGroup({
buildNotificationSetting(project, { channel: 'in_app' }), project: new FormControl(project),
buildNotificationSetting(project, { channel: 'mail' }), involved: new FormControl(false),
buildNotificationSetting(project, { channel: 'mail_digest' }), workPackageCreated: new FormControl(false),
]; workPackageProcessed: new FormControl(false),
workPackageScheduled: new FormControl(false),
workPackagePrioritized: new FormControl(false),
workPackageCommented: new FormControl(false),
}));
}
this.storeService.store.update( removeProjectSettings(index:number):void {
({ notifications }) => ({ this.settings.removeAt(index);
notifications: arrayAdd(notifications, added),
}),
);
} }
} }

@ -3,18 +3,5 @@
<div class="title-container"> <div class="title-container">
<h2 [textContent]="text.title"></h2> <h2 [textContent]="text.title"></h2>
</div> </div>
<ul class="toolbar-items">
<li
*ngIf="(projectSettings$ | async).length > 0"
class="toolbar-item"
>
<button
class="button"
(click)="removeAll()"
>
<span [textContent]="text.remove_projects"></span>
</button>
</li>
</ul>
</div> </div>
</div> </div>

@ -15,7 +15,6 @@ export class NotificationsSettingsToolbarComponent {
text = { text = {
title: this.I18n.t('js.notifications.settings.title'), title: this.I18n.t('js.notifications.settings.title'),
remove_projects: this.I18n.t('js.notifications.settings.remove_projects'),
}; };
constructor( constructor(

@ -8,20 +8,26 @@ import { UserPreferencesModel } from 'core-app/features/user-preferences/state/u
import { NotificationSetting } from 'core-app/features/user-preferences/state/notification-setting.model'; import { NotificationSetting } from 'core-app/features/user-preferences/state/notification-setting.model';
export class UserPreferencesQuery extends Query<UserPreferencesModel> { export class UserPreferencesQuery extends Query<UserPreferencesModel> {
/** All notification settings */
notificationSettings$ = this.select('notifications'); notificationSettings$ = this.select('notifications');
/** Notification settings grouped by Project */
notificationsGroupedByProject$:Observable<{ [key:string]:NotificationSetting[] }> = this notificationsGroupedByProject$:Observable<{ [key:string]:NotificationSetting[] }> = this
.notificationSettings$ .notificationSettings$
.pipe( .pipe(
map((notifications) => _.groupBy(notifications, (setting) => setting._links.project.title || 'global')), map((settings) => settings.filter((setting) => setting.channel === 'in_app' && setting._links.project.href)),
map((settings) => _.groupBy(settings, (setting) => setting._links.project.title)),
);
/** Notification settings grouped by Project */
notificationsForGlobal$:Observable<NotificationSetting|undefined> = this
.notificationSettings$
.pipe(
map((notifications) => notifications.find((setting) => setting.channel === 'in_app' && setting._links.project.href === null)),
); );
projectNotifications$ = this projectNotifications$ = this
.notificationSettings$ .notificationSettings$
.pipe( .pipe(
map((settings) => settings.filter((notification) => notification._links.project.href !== null)), map((settings) => settings.filter((setting) => setting.channel === 'in_app' && setting._links.project.href !== null)),
); );
/** Selected projects */ /** Selected projects */

@ -9,7 +9,6 @@ import { OPSharedModule } from 'core-app/shared/shared.module';
import { OpenprojectAutocompleterModule } from 'core-app/shared/components/autocompleter/openproject-autocompleter.module'; import { OpenprojectAutocompleterModule } from 'core-app/shared/components/autocompleter/openproject-autocompleter.module';
import { UserPreferencesService } from 'core-app/features/user-preferences/state/user-preferences.service'; import { UserPreferencesService } from 'core-app/features/user-preferences/state/user-preferences.service';
import { NotificationsSettingsPageComponent } from 'core-app/features/user-preferences/notifications-settings/page/notifications-settings-page.component'; import { NotificationsSettingsPageComponent } from 'core-app/features/user-preferences/notifications-settings/page/notifications-settings-page.component';
import { NotificationSettingRowComponent } from 'core-app/features/user-preferences/notifications-settings/row/notification-setting-row.component';
import { NotificationSettingInlineCreateComponent } from 'core-app/features/user-preferences/notifications-settings/inline-create/notification-setting-inline-create.component'; import { NotificationSettingInlineCreateComponent } from 'core-app/features/user-preferences/notifications-settings/inline-create/notification-setting-inline-create.component';
import { MY_ACCOUNT_ROUTES } from 'core-app/features/user-preferences/user-preferences.routes'; import { MY_ACCOUNT_ROUTES } from 'core-app/features/user-preferences/user-preferences.routes';
import { NotificationsSettingsToolbarComponent } from './notifications-settings/toolbar/notifications-settings-toolbar.component'; import { NotificationsSettingsToolbarComponent } from './notifications-settings/toolbar/notifications-settings-toolbar.component';
@ -24,7 +23,6 @@ import { ImmediateReminderSettingsComponent } from 'core-app/features/user-prefe
], ],
declarations: [ declarations: [
NotificationsSettingsPageComponent, NotificationsSettingsPageComponent,
NotificationSettingRowComponent,
NotificationSettingInlineCreateComponent, NotificationSettingInlineCreateComponent,
NotificationsSettingsToolbarComponent, NotificationsSettingsToolbarComponent,
NotificationSettingsTableComponent, NotificationSettingsTableComponent,

@ -12,6 +12,7 @@ import {
TemplateRef, TemplateRef,
ViewChild, ViewChild,
SimpleChanges, SimpleChanges,
HostBinding,
} from '@angular/core'; } from '@angular/core';
import { DropdownPosition, NgSelectComponent } from '@ng-select/ng-select'; import { DropdownPosition, NgSelectComponent } from '@ng-select/ng-select';
import { import {
@ -48,6 +49,8 @@ import { OpAutocompleterOptionTemplateDirective } from './directives/op-autocomp
// in order to use it, you only need to pass the data type and its filters // in order to use it, you only need to pass the data type and its filters
// you also can change the value of ng-select default options by changing @inputs and @outputs // you also can change the value of ng-select default options by changing @inputs and @outputs
export class OpAutocompleterComponent extends UntilDestroyedMixin implements AfterViewInit, OnChanges { export class OpAutocompleterComponent extends UntilDestroyedMixin implements AfterViewInit, OnChanges {
@HostBinding('class.op-autocompleter') className = true;
@Input() public filters?:IAPIFilter[] = []; @Input() public filters?:IAPIFilter[] = [];
@Input() public resource:resource; @Input() public resource:resource;

@ -1,4 +1,5 @@
<op-form-field <op-form-field
*ngIf="field.type !== 'booleanInput'"
[control]="field?.formControl" [control]="field?.formControl"
[label]="to?.label" [label]="to?.label"
[hidden]="field?.hide" [hidden]="field?.hide"
@ -8,7 +9,6 @@
[helpTextAttributeScope]="to?.helpTextAttributeScope || dynamicFormComponent?.helpTextAttributeScope" [helpTextAttributeScope]="to?.helpTextAttributeScope || dynamicFormComponent?.helpTextAttributeScope"
[showValidationErrorOn]="to?.showValidationErrorOn || dynamicFormComponent?.showValidationErrorsOn" [showValidationErrorOn]="to?.showValidationErrorOn || dynamicFormComponent?.showValidationErrorsOn"
[attr.data-qa-field-name]="to?.property" [attr.data-qa-field-name]="to?.property"
[ngClass]="{ 'op-form-field_checkbox': field.type === 'booleanInput' }"
> >
<ng-container #fieldComponent slot="input"></ng-container> <ng-container #fieldComponent slot="input"></ng-container>
@ -18,3 +18,23 @@
slot="errors" slot="errors"
></formly-validation-message> ></formly-validation-message>
</op-form-field> </op-form-field>
<op-checkbox-field
*ngIf="field.type === 'booleanInput'"
[control]="field?.formControl"
[label]="to?.label"
[hidden]="field?.hide"
[required]="to?.required"
[helpTextAttribute]="to?.property"
[helpTextAttributeScope]="to?.helpTextAttributeScope || dynamicFormComponent?.helpTextAttributeScope"
[showValidationErrorOn]="to?.showValidationErrorOn || dynamicFormComponent?.showValidationErrorsOn"
[attr.data-qa-field-name]="to?.property"
>
<ng-container #fieldComponent slot="input"></ng-container>
<formly-validation-message
class="op-form-field--error"
[field]="field"
slot="errors"
></formly-validation-message>
</op-checkbox-field>

@ -0,0 +1,45 @@
<ng-container *ngIf="!hidden">
<label class="op-form-field--label-wrap op-checkbox-field--label-wrap">
<div
class="op-form-field--input op-checkbox-field--input"
[attr.aria-describedby]="describedByID"
>
<ng-content select="[slot=input]"></ng-content>
</div>
<div
*ngIf="label"
class="op-form-field--label op-checkbox-field--label"
>
<span
*ngIf="showErrorMessage"
class="Hidden for sighted"
>Invalid</span>
{{ label }}
<span *ngIf="required" class="op-form-field--label-indicator">*</span>
<attribute-help-text
[attribute]="helpTextAttribute"
[attributeScope]="helpTextAttributeScope"
></attribute-help-text>
</div>
<div
class="op-form-field--description op-checkbox-field--description"
[id]="descriptionID"
>
<ng-content select="[slot=description]"></ng-content>
</div>
</label>
<div class="op-form-field--help-text">
<ng-content select="[slot=help-text]"></ng-content>
</div>
<div
class="op-form-field--errors"
*ngIf="showErrorMessage"
[id]="errorsID"
>
<ng-content select="[slot=errors]"></ng-content>
</div>
</ng-container>

@ -0,0 +1,80 @@
import {
Component,
ContentChild,
HostBinding,
Input,
Optional,
} from '@angular/core';
import {
AbstractControl,
FormGroupDirective,
NgControl,
} from '@angular/forms';
@Component({
selector: 'op-checkbox-field',
templateUrl: './checkbox-field.component.html',
})
export class OpCheckboxFieldComponent {
@HostBinding('class.op-form-field') className = true;
@HostBinding('class.op-checkbox-field') classNameCheckbox = true;
@HostBinding('class.op-form-field_invalid') get errorClassName():boolean {
return this.showErrorMessage;
}
@Input() label = '';
@Input() hidden = false;
@Input() required = false;
@Input() showValidationErrorOn:'change' | 'blur' | 'submit' | 'never' = 'submit';
@Input() control?:AbstractControl;
@Input() helpTextAttribute?:string;
@Input() helpTextAttributeScope?:string;
@ContentChild(NgControl) ngControl:NgControl;
internalID = `op-checkbox-field-${+new Date()}`;
get errorsID():string {
return `${this.internalID}-errors`;
}
get descriptionID():string {
return `${this.internalID}-description`;
}
get describedByID():string {
return this.showErrorMessage ? this.errorsID : this.descriptionID;
}
get formControl():AbstractControl|undefined|null {
return this.ngControl?.control || this.control;
}
get showErrorMessage():boolean {
if (!this.formControl) {
return false;
}
if (this.showValidationErrorOn === 'submit') {
return this.formControl.invalid && this.formGroupDirective?.submitted;
} if (this.showValidationErrorOn === 'blur') {
return this.formControl.invalid && this.formControl.touched;
} if (this.showValidationErrorOn === 'change') {
return this.formControl.invalid && this.formControl.dirty;
}
return false;
}
constructor(
@Optional() private formGroupDirective:FormGroupDirective,
) {}
}

@ -0,0 +1,34 @@
.op-checkbox-field
&--label-wrap
display: grid
grid-template-columns: auto 1fr
grid-template-rows: auto auto
&--input
grid-column-start: 1
grid-column-end: 2
grid-row-start: 1
grid-row-end: 3
align-self: center
padding-right: 1rem
margin-bottom: 0
&--label
grid-column-start: 2
grid-column-end: 2
grid-row-start: 1
grid-row-end: 2
margin-bottom: 0
&--description
grid-column-start: 2
grid-column-end: 2
grid-row-start: 2
grid-row-end: 3
margin-bottom: 0
&--label,
&--description
white-space: pre-line
word-break: break-word
word-wrap: break-word

@ -16,7 +16,7 @@ export class OpFormFieldComponent {
@Input() label = ''; @Input() label = '';
@Input() noWrapLabel = true; @Input() noWrapLabel = false;
@Input() required = false; @Input() required = false;

@ -20,7 +20,7 @@
margin: 0 margin: 0
&--label &--label
font-size: 14px font-size: 1rem
font-weight: bold font-weight: bold
line-height: 1.2 line-height: 1.2
@ -34,6 +34,9 @@
&--help-text &--help-text
font-size: 12px font-size: 12px
&--description *:last-child
margin-bottom: 0
&--errors &--errors
display: flex display: flex
flex-direction: column flex-direction: column
@ -48,19 +51,3 @@
&--errors &--errors
&:empty &:empty
display: none display: none
// Checkbox mode
$off: &
&_checkbox
#{$off}--label-wrap
flex-direction: row
flex-wrap: wrap
#{$off}--input
order: 1
margin-right: 1rem
#{$off}--label
order: 2
#{$off}--description
order: 3

@ -17,6 +17,7 @@
> .op-form--fieldset, > .op-form--fieldset,
> .op-form--field, > .op-form--field,
> .op-form-field, > .op-form-field,
> .op-checkbox-field,
> .op-option-list, > .op-option-list,
> .op-highlighted-input, > .op-highlighted-input,
> .button > .button

@ -1,4 +1,5 @@
@import './form-field/form-field' @import './form-field/form-field'
@import './checkbox-field/checkbox-field'
@import './form' @import './form'
@import './fieldset' @import './fieldset'
@import './highlighted-input' @import './highlighted-input'

@ -0,0 +1,6 @@
.op-scrollable-table
max-width: 100%
overflow-x: scroll
.op-table
width: auto

@ -0,0 +1,18 @@
.op-table
border-collapse: collapse
width: 100%
&--cell
padding: 12px 16px
border: 1px solid #cccccc
text-align: center
&_heading
background-color: #f3f3f3
font-weight: bold
text-align: left
&_soft-heading
background-color: transparent
text-align: left
font-weight: normal

@ -78,6 +78,7 @@ import { RemoteFieldUpdaterComponent } from './components/remote-field-updater/r
import { ShowSectionDropdownComponent } from './components/hide-section/show-section-dropdown.component'; import { ShowSectionDropdownComponent } from './components/hide-section/show-section-dropdown.component';
import { SlideToggleComponent } from './components/slide-toggle/slide-toggle.component'; import { SlideToggleComponent } from './components/slide-toggle/slide-toggle.component';
import { DynamicBootstrapModule } from './components/dynamic-bootstrap/dynamic-bootstrap.module'; import { DynamicBootstrapModule } from './components/dynamic-bootstrap/dynamic-bootstrap.module';
import { OpCheckboxFieldComponent } from './components/forms/checkbox-field/checkbox-field.component';
import { OpFormFieldComponent } from './components/forms/form-field/form-field.component'; import { OpFormFieldComponent } from './components/forms/form-field/form-field.component';
import { OpFormBindingDirective } from './components/forms/form-field/form-binding.directive'; import { OpFormBindingDirective } from './components/forms/form-field/form-binding.directive';
import { OpOptionListComponent } from './components/option-list/option-list.component'; import { OpOptionListComponent } from './components/option-list/option-list.component';
@ -179,7 +180,7 @@ export function bootstrapModule(injector:Injector) {
SlideToggleComponent, SlideToggleComponent,
// Autocompleter OpCheckboxFieldComponent,
OpFormFieldComponent, OpFormFieldComponent,
OpFormBindingDirective, OpFormBindingDirective,
OpOptionListComponent, OpOptionListComponent,
@ -231,6 +232,7 @@ export function bootstrapModule(injector:Injector) {
// filter // filter
SlideToggleComponent, SlideToggleComponent,
OpCheckboxFieldComponent,
OpFormFieldComponent, OpFormFieldComponent,
OpFormBindingDirective, OpFormBindingDirective,
OpOptionListComponent, OpOptionListComponent,

@ -5,5 +5,7 @@
@import 'bubble/bubble' @import 'bubble/bubble'
@import '../../app/shared/components/forms' @import '../../app/shared/components/forms'
@import '../../app/shared/components/option-list/option-list' @import '../../app/shared/components/option-list/option-list'
@import '../../app/shared/components/table/table'
@import '../../app/shared/components/table/scrollable-table'
@import 'export-options/export-options' @import 'export-options/export-options'
@import 'select/select' @import 'select/select'

@ -51,3 +51,10 @@ h4
border-bottom: 1px dotted #bbbbbb border-bottom: 1px dotted #bbbbbb
padding: 0 0 5px 0 padding: 0 0 5px 0
margin: 0 0 20px 0 margin: 0 0 20px 0
h5
color: var(--h5-font-color)
font-weight: bold
border-bottom: none
padding: 0
margin: 10px 0 0 0

@ -15,26 +15,6 @@ describe "Digest email", type: :feature, js: true do
current_user do current_user do
FactoryBot.create :user, FactoryBot.create :user,
notification_settings: [ notification_settings: [
FactoryBot.build(:mail_notification_setting,
involved: false,
watched: false,
mentioned: false,
work_package_commented: false,
work_package_created: false,
work_package_processed: false,
work_package_prioritized: false,
work_package_scheduled: false,
all: false),
FactoryBot.build(:in_app_notification_setting,
involved: false,
watched: false,
mentioned: false,
work_package_commented: false,
work_package_created: false,
work_package_processed: false,
work_package_prioritized: false,
work_package_scheduled: false,
all: false),
FactoryBot.build(:mail_digest_notification_setting, FactoryBot.build(:mail_digest_notification_setting,
involved: true, involved: true,
watched: true, watched: true,
@ -44,7 +24,7 @@ describe "Digest email", type: :feature, js: true do
work_package_processed: true, work_package_processed: true,
work_package_prioritized: true, work_package_prioritized: true,
work_package_scheduled: true, work_package_scheduled: true,
all: false) all: true)
] ]
end end
@ -60,49 +40,6 @@ describe "Digest email", type: :feature, js: true do
end end
it 'sends a digest mail based on the configuration', with_settings: { journal_aggregation_time_minutes: 0 } do it 'sends a digest mail based on the configuration', with_settings: { journal_aggregation_time_minutes: 0 } do
# Configure the digest
notification_settings_page.visit!
notification_settings_page.expect_setting channel: :mail_digest,
project: nil,
involved: true,
mentioned: true,
watched: true,
work_package_commented: true,
work_package_created: true,
work_package_processed: true,
work_package_prioritized: true,
work_package_scheduled: true,
all: false
notification_settings_page.configure_channel :mail_digest,
project: nil,
involved: false,
mentioned: true,
watched: true,
work_package_commented: false,
work_package_created: false,
work_package_processed: false,
work_package_prioritized: false,
work_package_scheduled: false,
all: false
notification_settings_page.add_row(mute_project)
notification_settings_page.configure_channel :mail_digest,
project: mute_project,
involved: false,
mentioned: false,
watched: false,
work_package_commented: false,
work_package_created: false,
work_package_processed: false,
work_package_prioritized: false,
work_package_scheduled: false,
all: false
notification_settings_page.save
# Perform some actions the user listens to # Perform some actions the user listens to
User.execute_as other_user do User.execute_as other_user do
note = <<~NOTE note = <<~NOTE

@ -1,67 +1,47 @@
shared_examples 'notification settings workflow' do shared_examples 'notification settings workflow' do
describe 'with another project the user can see' do describe 'with another project the user can see' do
let!(:project) { FactoryBot.create :project } let!(:project) { FactoryBot.create :project }
let!(:project_alt) { FactoryBot.create :project }
let!(:role) { FactoryBot.create :role, permissions: %i[view_project] } let!(:role) { FactoryBot.create :role, permissions: %i[view_project] }
let!(:member) { FactoryBot.create :member, user: user, project: project, roles: [role] } let!(:member) { FactoryBot.create :member, user: user, project: project, roles: [role] }
let!(:member_two) { FactoryBot.create :member, user: user, project: project_alt, roles: [role] }
it 'allows to control notification settings' do it 'allows to control notification settings' do
# Expect default settings # Expect default settings
settings_page.expect_represented settings_page.expect_represented
# Add setting for the project # Add projects columns
settings_page.add_row project settings_page.add_project project
settings_page.add_project project_alt
# Set settings for project email # Set settings for project email
settings_page.configure_channel :mail, settings_page.configure_global involved: true,
project: project, work_package_commented: true,
involved: true, work_package_created: true,
mentioned: true, work_package_processed: true,
watched: true, work_package_prioritized: true,
work_package_commented: true, work_package_scheduled: true
work_package_created: true,
work_package_processed: true,
work_package_prioritized: true,
work_package_scheduled: true,
all: false
# Set settings for project email # Set settings for project email
settings_page.configure_channel :in_app, settings_page.configure_project project: project,
project: project,
involved: true, involved: true,
mentioned: true,
watched: false,
work_package_commented: false, work_package_commented: false,
work_package_created: false, work_package_created: false,
work_package_processed: false, work_package_processed: false,
work_package_prioritized: false, work_package_prioritized: false,
work_package_scheduled: false, work_package_scheduled: false
all: false
# Set settings for project email digest
settings_page.configure_channel :mail_digest,
project: project,
involved: false,
mentioned: true,
watched: false,
work_package_commented: true,
work_package_created: true,
work_package_processed: true,
work_package_prioritized: true,
work_package_scheduled: true,
all: true
settings_page.save settings_page.save
user.reload user.reload
notification_settings = user.notification_settings notification_settings = user.notification_settings
expect(notification_settings.count).to eq 6 expect(notification_settings.count).to eq 9
expect(notification_settings.where(project: project).count).to eq 3 expect(notification_settings.where(project: project).count).to eq 3
in_app = notification_settings.find_by(project: project, channel: :in_app) in_app = notification_settings.find_by(project: project, channel: :in_app)
expect(in_app.involved).to be_truthy expect(in_app.involved).to be_truthy
expect(in_app.mentioned).to be_truthy expect(in_app.mentioned).to be_truthy
expect(in_app.watched).to be_falsey expect(in_app.watched).to be_truthy
expect(in_app.all).to be_falsey
expect(in_app.work_package_commented).to be_falsey expect(in_app.work_package_commented).to be_falsey
expect(in_app.work_package_created).to be_falsey expect(in_app.work_package_created).to be_falsey
expect(in_app.work_package_processed).to be_falsey expect(in_app.work_package_processed).to be_falsey
@ -72,23 +52,21 @@ shared_examples 'notification settings workflow' do
expect(mail.involved).to be_truthy expect(mail.involved).to be_truthy
expect(mail.mentioned).to be_truthy expect(mail.mentioned).to be_truthy
expect(mail.watched).to be_truthy expect(mail.watched).to be_truthy
expect(mail.all).to be_falsey expect(mail.work_package_commented).to be_falsey
expect(mail.work_package_commented).to be_truthy expect(mail.work_package_created).to be_falsey
expect(mail.work_package_created).to be_truthy expect(mail.work_package_processed).to be_falsey
expect(mail.work_package_processed).to be_truthy expect(mail.work_package_prioritized).to be_falsey
expect(mail.work_package_prioritized).to be_truthy expect(mail.work_package_scheduled).to be_falsey
expect(mail.work_package_scheduled).to be_truthy
mail_digest = notification_settings.find_by(project: project, channel: :mail_digest) mail_digest = notification_settings.find_by(project: project, channel: :mail_digest)
expect(mail_digest.involved).to be_falsey expect(mail_digest.involved).to be_truthy
expect(mail_digest.mentioned).to be_truthy expect(mail_digest.mentioned).to be_truthy
expect(mail_digest.watched).to be_falsey expect(mail_digest.watched).to be_truthy
expect(mail_digest.all).to be_truthy expect(mail_digest.work_package_commented).to be_falsey
expect(mail_digest.work_package_commented).to be_truthy expect(mail_digest.work_package_created).to be_falsey
expect(mail_digest.work_package_created).to be_truthy expect(mail_digest.work_package_processed).to be_falsey
expect(mail_digest.work_package_processed).to be_truthy expect(mail_digest.work_package_prioritized).to be_falsey
expect(mail_digest.work_package_prioritized).to be_truthy expect(mail_digest.work_package_scheduled).to be_falsey
expect(mail_digest.work_package_scheduled).to be_truthy
# Trying to add the same project again will not be possible (Regression #38072) # Trying to add the same project again will not be possible (Regression #38072)
click_button 'Add setting for project' click_button 'Add setting for project'

@ -45,62 +45,58 @@ module Pages
def expect_represented def expect_represented
user.notification_settings.each do |setting| user.notification_settings.each do |setting|
within_channel(setting.channel, project: setting.project&.name) do expect_global_represented(setting)
expect_setting setting.attributes.symbolize_keys # expect_project_represented(setting)
end
end end
end end
def expect_setting(setting) def expect_global_represented(setting)
channel_name = I18n.t("js.notifications.channels.#{setting[:channel]}") %i[
expect(page).to have_selector('td', text: channel_name) involved
work_package_commented
%i[involved mentioned watched all].each do |type| work_package_created
expect(page).to have_selector("input[type='checkbox'][data-qa-notification-type='#{type}']") do |checkbox| work_package_processed
if setting[:all] && type != :all work_package_prioritized
checkbox.disabled? work_package_scheduled
else ].each do |type|
checkbox.checked? == setting[type] expect(page).to have_selector("input[type='checkbox'][data-qa-global-notification-type='#{type}']") do |checkbox|
end checkbox.checked? == setting[type]
end end
end end
end end
def expect_project(project) def expect_project(project)
expect(page).to have_selector('td', text: project.name) expect(page).to have_selector('th', text: project.name)
end end
def add_row(project) def add_project(project)
click_button 'Add setting for project' click_button 'Add setting for project'
container = page.find('[data-qa-selector="notification-setting-inline-create"] ng-select') container = page.find('[data-qa-selector="notification-setting-inline-create"] ng-select')
select_autocomplete container, query: project.name, results_selector: 'body' select_autocomplete container, query: project.name, results_selector: 'body'
expect_project project expect_project project
end end
def configure_channel(channel, project: nil, **types) def configure_global(**types)
within_channel(channel, project: project) do types.each(&method(:set_global_option))
types.each(&method(:set_option))
end
end end
def set_option(type, checked) def set_global_option(type, checked)
checkbox = page.find "input[type='checkbox'][data-qa-notification-type='#{type}']" checkbox = page.find "input[type='checkbox'][data-qa-global-notification-type='#{type}']"
checked ? checkbox.check : checkbox.uncheck checked ? checkbox.check : checkbox.uncheck
end end
def save def configure_project(project: nil, **types)
click_button 'Save' types.each { |type| set_project_option(*type, project) }
expect_notification message: 'Successful update.'
end end
def within_channel(channel, project: nil, &block) def set_project_option(type, checked, project)
raise(ArgumentError, "Invalid channel") unless NotificationSetting.channels.include?(channel.to_sym) checkbox = page.find "input[type='checkbox'][data-qa-project='#{project}'][data-qa-project-notification-type='#{type}']"
checked ? checkbox.check : checkbox.uncheck
end
project = 'global' if project.nil? def save
page.within( click_button 'Save'
"[data-qa-notification-project='#{project}'][data-qa-notification-channel='#{channel}']", expect_notification message: 'Successful update.'
&block
)
end end
end end
end end

Loading…
Cancel
Save