Use reminders form component for the admin view as well and remove now outdated angular components

This commit is contained in:
Henriette Darge
2026-03-26 15:15:29 +01:00
parent 35d481edb7
commit 3f0f3cfca0
29 changed files with 48 additions and 988 deletions
@@ -48,12 +48,12 @@ module My
{
name: "notifications",
path: helpers.my_notifications_path(tab: "notifications"),
label: I18n.t("js.notifications.settings.title")
label: t("my_account.notifications_and_email.tabs.notifications")
},
{
name: "reminders",
path: helpers.my_notifications_path(tab: "reminders"),
label: I18n.t("js.reminders.settings.title")
label: t("my_account.notifications_and_email.tabs.email_reminders")
}
]
end
@@ -31,7 +31,7 @@ See COPYRIGHT and LICENSE files for more details.
settings_primer_form_with(
model: @user.pref,
scope: "pref[immediate_reminders]",
url: { action: "update_settings" },
url: update_url,
method: :patch,
data: { turbo: false }
) do |form|
@@ -43,7 +43,7 @@ See COPYRIGHT and LICENSE files for more details.
settings_primer_form_with(
model: daily_reminders_form_model,
scope: "pref[daily_reminders]",
url: { action: "update_settings" },
url: update_url,
method: :patch,
data: {
turbo: false,
@@ -59,7 +59,7 @@ See COPYRIGHT and LICENSE files for more details.
settings_primer_form_with(
model: @user.pref,
scope: :pref,
url: { action: "update_settings" },
url: update_url,
method: :patch,
data: { turbo: false }
) do |form|
@@ -71,7 +71,7 @@ See COPYRIGHT and LICENSE files for more details.
settings_primer_form_with(
model: pause_reminders_form_model,
scope: "pref[pause_reminders]",
url: { action: "update_settings" },
url: update_url,
method: :patch,
data: {
turbo: false,
@@ -87,7 +87,7 @@ See COPYRIGHT and LICENSE files for more details.
settings_primer_form_with(
model: global_notification_setting,
scope: :notification_setting,
url: { action: "update_email_alerts" },
url: update_email_alerts_url,
method: :patch,
data: { turbo: false }
) do |form|
@@ -33,13 +33,15 @@ module My
class ShowPageComponent < ApplicationComponent
include OpPrimer::FormHelpers
attr_reader :global_notification_setting
attr_reader :global_notification_setting, :update_url, :update_email_alerts_url
def initialize(user:, global_notification_setting:)
def initialize(user:, global_notification_setting:, update_url:, update_email_alerts_url:)
super
@user = user
@global_notification_setting = global_notification_setting
@update_url = update_url
@update_email_alerts_url = update_email_alerts_url
end
def daily_reminders_form_model
+12
View File
@@ -39,6 +39,7 @@ class UsersController < ApplicationController
before_action :find_user, only: %i[show
edit
update
update_email_alerts
change_status_info
change_status
destroy
@@ -109,6 +110,17 @@ class UsersController < ApplicationController
end
end
def update_email_alerts
global_setting = @user.notification_settings.find_or_initialize_by(project: nil)
if global_setting.update(permitted_params.notification_setting_email_alerts)
flash[:notice] = I18n.t(:notice_successful_update)
else
flash[:error] = I18n.t(:notice_failed_to_save_messages, count: global_setting.errors.count,
object: global_setting.class.model_name.human)
end
redirect_back_or_to edit_user_path(@user, tab: "reminders")
end
def update # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
update_params = build_user_update_params
call = ::Users::UpdateService.new(model: @user, user: current_user).call(update_params)
@@ -30,7 +30,7 @@
class My::Reminders::ImmediateRemindersForm < ApplicationForm
form do |f|
f.fieldset_group(title: helpers.t("my_account.email_reminders.immediate_reminders.title")) do |fg|
f.fieldset_group(title: helpers.t("my_account.email_reminders.immediate_reminders.title"), mt: 2) do |fg|
fg.check_box(
name: :mentioned,
label: helpers.t("my_account.email_reminders.immediate_reminders.mentioned")
+8 -1
View File
@@ -32,7 +32,14 @@ See COPYRIGHT and LICENSE files for more details.
<%= render(My::Notifications::ShowPageHeaderComponent.new) %>
<% if params[:tab] == "reminders" %>
<%= render(My::Reminders::ShowPageComponent.new(user: @user, global_notification_setting: @global_notification_setting)) %>
<%= render(
My::Reminders::ShowPageComponent.new(
user: @user,
global_notification_setting: @global_notification_setting,
update_url: { action: "update_settings" },
update_email_alerts_url: { action: "update_email_alerts" }
)
) %>
<% else %>
<%= angular_component_tag "opce-notification-settings" %>
<% end %>
+6 -1
View File
@@ -26,4 +26,9 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
<%= angular_component_tag "opce-reminder-settings", inputs: { userId: @user.id } %>
<%= render(My::Reminders::ShowPageComponent.new(
user: @user,
global_notification_setting: @user.notification_settings.find_or_initialize_by(project: nil),
update_url: user_path(@user),
update_email_alerts_url: update_email_alerts_user_path(@user)
)) %>
+3
View File
@@ -3528,6 +3528,9 @@ en:
my_account:
notifications_and_email:
title: "Notification and email"
tabs:
notifications: "Notification settings"
email_reminders: "Email reminders"
access_tokens:
description: "Provider tokens are issued by OpenProject, allowing other applications to access it. Client tokens are issued by other applications, allowing OpenProject to access them."
no_results:
-34
View File
@@ -701,40 +701,6 @@ en:
Please choose a project to create the work package in to see all attributes.
You can only select projects which have the type above activated.
reminders:
settings:
daily:
add_time: "Add time"
enable: "Enable daily email reminders"
explanation: "You will receive these reminders only for unread notifications and only at hours you specify. %{no_time_zone}"
no_time_zone: "Until you configure a time zone for your account, the times will be interpreted to be in UTC."
time_label: "Time %{counter}:"
title: "Send me daily email reminders for unread notifications"
workdays:
title: "Receive email reminders on these days"
immediate:
title: "Send me an email reminder"
mentioned: "Immediately when someone @mentions me"
personal_reminder: "Immediately when I receive a personal reminder"
alerts:
title: "Email alerts for other items (that are not work packages)"
explanation: >
Notifications today are limited to work packages.
You can choose to continue receiving email alerts for these events until they are included in notifications:
news_added: "News added"
news_commented: "Comment on a news item"
document_added: "Documents added"
forum_messages: "New forum messages"
wiki_page_added: "Wiki page added"
wiki_page_updated: "Wiki page updated"
membership_added: "Membership added"
membership_updated: "Membership updated"
title: "Email reminders"
pause:
label: "Temporarily pause daily email reminders"
first_day: "First day"
last_day: "Last day"
text_are_you_sure: "Are you sure?"
text_are_you_sure_to_cancel: "You have unsaved changes on this page. Are you sure you want to discard them?"
breadcrumb: "Breadcrumb"
+1
View File
@@ -950,6 +950,7 @@ Rails.application.routes.draw do
get "/change_status/:change_action" => "users#change_status_info", as: "change_status_info"
post :change_status
post :resend_invitation
patch :update_email_alerts
get :deletion_info
end
end
+2 -4
View File
@@ -138,9 +138,7 @@ import { OpenProjectJobStatusModule } from 'core-app/features/job-status/openpro
import {
NotificationsSettingsPageComponent,
} from 'core-app/features/user-preferences/notifications-settings/page/notifications-settings-page.component';
import {
ReminderSettingsPageComponent,
} from 'core-app/features/user-preferences/reminder-settings/page/reminder-settings-page.component';
import { OpenProjectMyAccountModule } from 'core-app/features/user-preferences/user-preferences.module';
import { OpAttachmentsComponent } from 'core-app/shared/components/attachments/attachments.component';
import {
@@ -391,7 +389,7 @@ export class OpenProjectModule implements DoBootstrap {
// TODO: These elements are now registered custom elements, but are actually single-use components. They should be removed when we move these pages to Rails.
registerCustomElement('opce-notification-settings', NotificationsSettingsPageComponent, { injector });
registerCustomElement('opce-reminder-settings', ReminderSettingsPageComponent, { injector });
registerCustomElement('opce-notification-center', InAppNotificationCenterComponent, { injector });
registerCustomElement('opce-wp-split-view', WorkPackageSplitViewEntryComponent, { injector });
registerCustomElement('opce-wp-full-view', WorkPackageFullViewEntryComponent, { injector });
@@ -1,19 +0,0 @@
<ng-container [formGroup]="form">
<div class="op-form--section-header">
<h3 [textContent]="text.title" class="op-form--section-header-title"></h3>
<p [textContent]="text.explanation"></p>
</div>
@for (setting of alerts; track setting) {
<spot-selector-field
[label]="text[setting]"
[control]="form.get(setting)"
>
<input
slot="input"
type="checkbox"
[formControlName]="setting"
/>
</spot-selector-field>
}
</ng-container>
@@ -1,62 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
OnInit,
} from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { UserPreferencesService } from 'core-app/features/user-preferences/state/user-preferences.service';
import {
UntypedFormGroup,
FormGroupDirective,
} from '@angular/forms';
export type EmailAlertType =
'newsAdded'|'newsCommented'|'documentAdded'|'forumMessages'|'wikiPageAdded'|
'wikiPageUpdated'|'membershipAdded'|'membershipUpdated';
export const emailAlerts:EmailAlertType[] = [
'newsAdded',
'newsCommented',
'documentAdded',
'forumMessages',
'wikiPageAdded',
'wikiPageUpdated',
'membershipAdded',
'membershipUpdated',
];
@Component({
selector: 'op-email-alerts-settings',
templateUrl: './email-alerts-settings.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class EmailAlertsSettingsComponent implements OnInit {
form:UntypedFormGroup;
alerts:EmailAlertType[] = emailAlerts;
text = {
title: this.I18n.t('js.reminders.settings.alerts.title'),
explanation: this.I18n.t('js.reminders.settings.alerts.explanation'),
newsAdded: this.I18n.t('js.reminders.settings.alerts.news_added'),
newsCommented: this.I18n.t('js.reminders.settings.alerts.news_commented'),
documentAdded: this.I18n.t('js.reminders.settings.alerts.document_added'),
forumMessages: this.I18n.t('js.reminders.settings.alerts.forum_messages'),
wikiPageAdded: this.I18n.t('js.reminders.settings.alerts.wiki_page_added'),
wikiPageUpdated: this.I18n.t('js.reminders.settings.alerts.wiki_page_updated'),
membershipAdded: this.I18n.t('js.reminders.settings.alerts.membership_added'),
membershipUpdated: this.I18n.t('js.reminders.settings.alerts.membership_updated'),
};
constructor(
private I18n:I18nService,
private storeService:UserPreferencesService,
private rootFormGroup:FormGroupDirective,
) {
}
ngOnInit():void {
this.form = this.rootFormGroup.control.get('emailAlerts') as UntypedFormGroup;
}
}
@@ -1,29 +0,0 @@
<ng-container [formGroup]="form">
<div class="op-form--section-header">
<h3 [textContent]="text.title" class="op-form--section-header-title"></h3>
</div>
<spot-selector-field
[label]="text.mentioned"
[control]="form.get('mentioned')"
>
<input
slot="input"
type="checkbox"
formControlName="mentioned"
data-qa-immediate-reminder="mentioned"
/>
</spot-selector-field>
<spot-selector-field
[label]="text.personalReminder"
[control]="form.get('personalReminder')"
>
<input
slot="input"
type="checkbox"
formControlName="personalReminder"
data-qa-immediate-reminder="personalReminder"
/>
</spot-selector-field>
</ng-container>
@@ -1,39 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
OnInit,
} from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { UserPreferencesService } from 'core-app/features/user-preferences/state/user-preferences.service';
import {
UntypedFormGroup,
FormGroupDirective,
} from '@angular/forms';
@Component({
selector: 'op-immediate-reminder-settings',
templateUrl: './immediate-reminder-settings.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class ImmediateReminderSettingsComponent implements OnInit {
form:UntypedFormGroup;
text = {
title: this.I18n.t('js.reminders.settings.immediate.title'),
explanation: this.I18n.t('js.reminders.settings.immediate.explanation'),
mentioned: this.I18n.t('js.reminders.settings.immediate.mentioned'),
personalReminder: this.I18n.t('js.reminders.settings.immediate.personal_reminder'),
};
constructor(
private I18n:I18nService,
private storeService:UserPreferencesService,
private rootFormGroup:FormGroupDirective,
) {
}
ngOnInit():void {
this.form = this.rootFormGroup.control.get('immediateReminders') as UntypedFormGroup;
}
}
@@ -1,19 +0,0 @@
@if (formInitialized) {
<form
[formGroup]="form"
(ngSubmit)="saveChanges()"
class="op-form"
>
<op-immediate-reminder-settings class="op-form--fieldset" />
<op-reminder-settings-daily-time class="op-form--fieldset" />
<op-workdays-settings class="op-form--fieldset" />
<op-email-alerts-settings class="op-form--fieldset" />
<div class="op-form--submit">
<button
class="button -primary"
[textContent]="text.save"
type="submit"
></button>
</div>
</form>
}
@@ -1,6 +0,0 @@
// TODO: remove once we have a standard
// styling for these section headings
.form--section-title
text-transform: initial
border-bottom: initial
margin-bottom: initial !important
@@ -1,193 +0,0 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnInit } from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
import { take } from 'rxjs/internal/operators/take';
import { UserPreferencesService } from 'core-app/features/user-preferences/state/user-preferences.service';
import { UntypedFormArray, UntypedFormBuilder } from '@angular/forms';
import {
DailyRemindersSettings,
ImmediateRemindersSettings,
IUserPreference,
PauseRemindersSettings,
} from 'core-app/features/user-preferences/state/user-preferences.model';
import {
emailAlerts,
EmailAlertType,
} from 'core-app/features/user-preferences/reminder-settings/email-alerts/email-alerts-settings.component';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { filter, withLatestFrom } from 'rxjs/operators';
import { filterObservable } from 'core-app/shared/helpers/rxjs/filterWith';
import { INotificationSetting } from 'core-app/features/user-preferences/state/notification-setting.model';
import { populateInputsFromDataset } from 'core-app/shared/components/dataset-inputs';
interface IReminderSettingsFormValue {
immediateReminders:ImmediateRemindersSettings,
dailyReminders:DailyRemindersSettings,
pauseReminders:Partial<PauseRemindersSettings>,
emailAlerts:Record<EmailAlertType, boolean>;
workdays:boolean[];
}
@Component({
templateUrl: './reminder-settings-page.component.html',
styleUrls: ['./reminder-settings-page.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class ReminderSettingsPageComponent extends UntilDestroyedMixin implements OnInit {
@Input() userId:string;
public form = this.fb.group({
immediateReminders: this.fb.group({
mentioned: this.fb.control(false),
personalReminder: this.fb.control(false),
}),
dailyReminders: this.fb.group({
enabled: this.fb.control(false),
times: this.fb.array([]),
}),
pauseReminders: this.fb.group({
enabled: this.fb.control(false),
firstDay: this.fb.control(''),
lastDay: this.fb.control(''),
}),
workdays: this.fb.array([
this.fb.control(false),
this.fb.control(true),
this.fb.control(true),
this.fb.control(true),
this.fb.control(true),
this.fb.control(true),
this.fb.control(false),
]),
emailAlerts: this.fb.group({
newsAdded: this.fb.control(false),
newsCommented: this.fb.control(false),
documentAdded: this.fb.control(false),
forumMessages: this.fb.control(false),
wikiPageAdded: this.fb.control(false),
wikiPageUpdated: this.fb.control(false),
membershipAdded: this.fb.control(false),
membershipUpdated: this.fb.control(false),
}),
});
text = {
title: this.I18n.t('js.reminders.settings.title'),
save: this.I18n.t('js.button_save'),
};
formInitialized = false;
constructor(
readonly elementRef:ElementRef,
readonly I18n:I18nService,
readonly storeService:UserPreferencesService,
readonly currentUserService:CurrentUserService,
readonly fb:UntypedFormBuilder,
readonly cdRef:ChangeDetectorRef,
) {
super();
populateInputsFromDataset(this);
}
ngOnInit():void {
this
.currentUserService
.user$
.pipe(take(1))
.subscribe((user) => {
this.userId = this.userId || user?.id!;
this.storeService.get(this.userId);
});
this.storeService.query.select()
.pipe(
filter((settings) => !!settings),
withLatestFrom(this.storeService.query.globalNotification$),
filterObservable(this.storeService.query.selectLoading(), (val) => !val),
)
.subscribe(([settings, globalSetting]) => {
this.buildForm(settings, globalSetting);
});
}
private buildForm(settings:IUserPreference, globalSetting:INotificationSetting) {
this.form.get('immediateReminders.mentioned')?.setValue(settings.immediateReminders.mentioned);
this.form.get('immediateReminders.personalReminder')?.setValue(settings.immediateReminders.personalReminder);
this.form.get('dailyReminders.enabled')?.setValue(settings.dailyReminders.enabled);
this.form.get('pauseReminders')?.patchValue(settings.pauseReminders);
const dailyReminderTimes = this.form.get('dailyReminders.times') as UntypedFormArray;
dailyReminderTimes.clear({ emitEvent: false });
[...settings.dailyReminders.times].sort().forEach((time) => {
dailyReminderTimes.push(this.fb.control(time), { emitEvent: false });
});
dailyReminderTimes.enable({ emitEvent: true });
const workdays = this.form.get('workdays') as UntypedFormArray;
for (let i = 0; i <= 6; i++) {
const control = workdays.at(i);
control.setValue(settings.workdays.includes(i + 1));
}
emailAlerts.forEach((alert) => {
this.form.get(`emailAlerts.${alert}`)?.setValue(globalSetting[alert]);
});
this.formInitialized = true;
this.cdRef.detectChanges();
}
public saveChanges():void {
const prefs = this.storeService.query.getValue();
const globalNotifications = prefs.notifications.filter((notification) => !notification._links.project.href);
const projectNotifications = prefs.notifications.filter((notification) => !!notification._links.project.href);
const reminderSettings = (this.form.value as IReminderSettingsFormValue);
const workdays = ReminderSettingsPageComponent.buildWorkdays(reminderSettings.workdays);
const pauseReminders = ReminderSettingsPageComponent.buildPauses(reminderSettings.pauseReminders);
const { dailyReminders, immediateReminders } = reminderSettings;
this.storeService.update(this.userId, {
...prefs,
workdays,
dailyReminders,
immediateReminders,
pauseReminders,
notifications: [
...globalNotifications.map((notification) => (
{
...notification,
...reminderSettings.emailAlerts,
}
)),
...projectNotifications,
],
});
}
private static buildWorkdays(formValues:boolean[]):number[] {
return formValues
.reduce(
(result, val, index) => {
if (val) {
return result.concat([index + 1]);
}
return result;
},
[] as number[],
);
}
private static buildPauses(formValues:Partial<PauseRemindersSettings>):Partial<PauseRemindersSettings> {
if (formValues.enabled) {
return formValues;
}
return { enabled: false };
}
}
@@ -1,23 +0,0 @@
<div
class="op-pause-reminders"
[formGroup]="form"
>
<spot-selector-field
class="op-pause-reminders--checkbox"
[label]="text.label"
[control]="form.get('enabled')"
>
<input
slot="input"
type="checkbox"
formControlName="enabled"
/>
</spot-selector-field>
@if ((enabled$ | async)) {
<op-basic-range-date-picker [required]="enabled$ | async"
[value]="selectedDates$ | async"
(valueChange)="setDates($event)"
/>
}
</div>
@@ -1,6 +0,0 @@
.op-pause-reminders
display: flex
align-items: center
&--checkbox
margin-right: 2rem
@@ -1,70 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
OnInit,
} from '@angular/core';
import {
UntypedFormGroup,
FormGroupDirective,
} from '@angular/forms';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import {
map,
startWith,
} from 'rxjs/operators';
import { Observable } from 'rxjs';
@Component({
selector: 'op-pause-reminders',
templateUrl: './pause-reminders.component.html',
styleUrls: ['./pause-reminders.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class PauseRemindersComponent implements OnInit {
form:UntypedFormGroup;
selectedDates$:Observable<[string, string]>;
enabled$:Observable<boolean>;
text = {
label: this.I18n.t('js.reminders.settings.pause.label'),
date_placeholder: this.I18n.t('js.placeholders.date'),
first_day: this.I18n.t('js.reminders.settings.pause.first_day'),
last_day: this.I18n.t('js.reminders.settings.pause.first_day'),
};
constructor(
private I18n:I18nService,
private rootFormGroup:FormGroupDirective,
) {
}
ngOnInit():void {
this.form = this.rootFormGroup.control.get('pauseReminders') as UntypedFormGroup;
this.selectedDates$ = this
.form
.valueChanges
.pipe(
startWith(this.form.value),
map((form:{ firstDay:string, lastDay:string }) => [form.firstDay, form.lastDay]),
);
this.enabled$ = this
.form
.valueChanges
.pipe(
startWith(this.form.value),
map((form:{ enabled:boolean }) => form.enabled),
);
}
setDates($event:[string, string]):void {
const [firstDay, lastDay] = $event;
this.form.patchValue({
firstDay,
lastDay,
});
}
}
@@ -1,73 +0,0 @@
@if ((selectedTimes$ | async); as selectedTimes) {
<ng-container
[formGroup]="form"
>
<div class="op-form--section-header">
<h3 [textContent]="text.title" class="op-form--section-header-title"></h3>
<p [textContent]="text.explanation"></p>
</div>
<spot-selector-field
[label]="text.enable"
[control]="form.get('enabled')">
<input
slot="input"
type="checkbox"
formControlName="enabled"
/>
</spot-selector-field>
@for (time of selectedTimes; track i; let i = $index) {
<div
class="op-reminder-settings-daily-time--row"
>
@if ((activeTimes$ | async); as activeTimes) {
<input
type="checkbox"
[ngModel]="isActive(time)"
(ngModelChange)="toggleActive($event, i, selectedTimes)"
[ngModelOptions]="{standalone: true}"
[disabled]="isDisabled(time, activeTimes)"
class="op-reminder-settings-daily-time--active"
attr.data-test-selector="op-settings-daily-time--active-{{i + 1}}">
}
<label
class="op-reminder-settings-daily-time--label"
[textContent]="text.timeLabel(i + 1)"
attr.for="op-reminder-settings-daily-time-{{i + 1}}--time">
</label>
<select
[ngModel]="time"
(ngModelChange)="changeTime($event, selectedTimes, i)"
[ngModelOptions]="{standalone: true}"
[disabled]="(enabled$ | async) === false"
class="op-reminder-settings-daily-time--time form--select -narrow"
attr.id="op-reminder-settings-daily-time-{{i + 1}}--time"
required="true">
@for (availableTime of availableTimes; track availableTime) {
<option
[value]="availableTime"
[disabled]="time !== availableTime && selectedTimes.includes(availableTime)">
{{timeLabel(availableTime)}}
</option>
}
</select>
@if (timeRemovable$ | async) {
<button
class="spot-link op-reminder-settings-daily-time--remove"
type="button"
(click)="removeTime(selectedTimes, i)"
attr.data-test-selector="op-settings-daily-time--remove-{{i + 1}}">
<op-icon icon-classes="icon-small icon-remove icon4" />
</button>
}
</div>
}
<button
class="button op-reminder-settings-daily-time--add"
type="button"
[disabled]="nonAddable$ | async"
(click)="addTime(selectedTimes)">
<i class="button--icon icon-add"></i>
<span class="button--text">{{text.addTime}}</span>
</button>
</ng-container>
}
@@ -1,37 +0,0 @@
@import "helpers"
.op-reminder-settings-daily-time
&--enable
display: flex
align-items: center
margin-top: 30px
margin-bottom: 10px
&--row
margin-left: 40px
line-height: 45px
display: flex
align-items: center
&:nth-of-type(1)
margin-top: 10px
&--active
flex: 0 0 25px
&--label
flex: 0 0 80px
margin-bottom: 0
@include text-shortener
&--time
height: 32px
width: 150px
&--remove
padding-left: 10px
&--add
margin-top: 20px
margin-left: 40px
width: auto
@@ -1,251 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
OnInit,
} from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import {
map,
shareReplay,
startWith,
} from 'rxjs/operators';
import {
combineLatest,
NEVER,
Observable,
} from 'rxjs';
import { UserPreferencesService } from 'core-app/features/user-preferences/state/user-preferences.service';
import {
UntypedFormArray,
UntypedFormControl,
UntypedFormGroup,
FormGroupDirective,
} from '@angular/forms';
import { ConfigurationService } from 'core-app/core/config/configuration.service';
import moment from 'moment';
@Component({
selector: 'op-reminder-settings-daily-time',
templateUrl: './reminder-settings-daily-time.component.html',
styleUrls: ['./reminder-settings-daily-time.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class ReminderSettingsDailyTimeComponent implements OnInit {
// All times that are available in a day with a 1 hour gap between each.
// ['00:00', '01:00', ..., '24:00']
public availableTimes:string[] = ReminderSettingsDailyTimeComponent.setupAvailableTimes();
// The times (hours) that the user deactivated. Those are only stored within the component
// as the inactive hours are not persisted. This list is then interleaved with the list
// of times stored in the backend. As the order of the times should be kept,
// the position needs to be maintained.
// Upon a reload of the page, it is accepted to loose this information.
public inactiveTimes:{ position:number, time:string }[] = [];
public form:UntypedFormGroup;
// Hours suggested if a new time is added by a user.
public suggestedTimes = ['08:00', '12:00', '15:00', '18:00'];
// Whether the reminder are active at all.
public enabled$:Observable<boolean>;
// The active times as present in the store interleaved with the inactive
// times.
public selectedTimes$:Observable<string[]> = NEVER;
// Times that are truly active:
// * the reminders are not disabled completely
// * the times are not inactive individually.
public activeTimes$:Observable<string[]> = NEVER;
// Times can only be removed if the element is active and if there is more than one time present.
public timeRemovable$:Observable<boolean> = NEVER;
// Times can not be added if the element is disabled or if all the possible times have already been added (active or not).
public nonAddable$:Observable<boolean> = NEVER;
text = {
title: this.I18n.t('js.reminders.settings.daily.title'),
explanation: this.I18n.t('js.reminders.settings.daily.explanation',
{ no_time_zone: this.configurationService.isTimezoneSet() ? '' : this.I18n.t('js.reminders.settings.daily.no_time_zone') }),
timeLabel: (counter:number):string => this.I18n.t('js.reminders.settings.daily.time_label', { counter }),
addTime: this.I18n.t('js.reminders.settings.daily.add_time'),
enable: this.I18n.t('js.reminders.settings.daily.enable'),
};
constructor(
private I18n:I18nService,
private storeService:UserPreferencesService,
private rootFormGroup:FormGroupDirective,
private configurationService:ConfigurationService,
) {
}
ngOnInit():void {
this.form = this.rootFormGroup.control.get('dailyReminders') as UntypedFormGroup;
this.enabled$ = this
.form
.valueChanges
.pipe(
startWith(() => this.form.get('enabled')?.value as boolean),
map(() => this.form.get('enabled')?.value as boolean),
shareReplay(1),
);
this.selectedTimes$ = (this
.form
.get('times') as UntypedFormArray)
.valueChanges
.pipe(
startWith(() => this.form.get('times')?.value as UntypedFormArray),
map(() => {
const timesArray = this.form.get('times') as UntypedFormArray;
const activeTimes = timesArray.controls.map((c) => c.value as string);
this
.inactiveTimes
.sort((a, b) => a.position - b.position)
.forEach((inactiveTime) => {
activeTimes.splice(inactiveTime.position, 0, inactiveTime.time);
});
return activeTimes;
}),
shareReplay(1),
);
this.timeRemovable$ = combineLatest([
this.enabled$,
this.selectedTimes$,
]).pipe(map(([enabled, selectedTimes]) => enabled && selectedTimes.length > 1));
this.nonAddable$ = combineLatest([
this.enabled$,
this.selectedTimes$,
]).pipe(map(([enabled, selectedTimes]) => !enabled || selectedTimes.length === this.availableTimes.length));
this.activeTimes$ = combineLatest([
this.enabled$,
this.selectedTimes$,
]).pipe(
map(([enabled, times]) => (enabled ? times : [])),
);
}
addTime(selectedTimes:string[]):void {
const time = this.firstAvailableSuggested(selectedTimes) || this.firstAfterSelected(selectedTimes);
if (time) {
this.storeTimes(selectedTimes.concat(time));
}
}
changeTime(newTime:string, selectedTimes:string[], index:number):void {
selectedTimes.splice(index, 1, newTime);
this.storeTimes(selectedTimes);
}
isActive(time:string):boolean {
return !this.inactiveTimes.find((inactive) => inactive.time === time);
}
removeTime(selectedTimes:string[], index:number):void {
this.inactiveTimes = this
.inactiveTimes
.filter((inactiveTime) => inactiveTime.time !== selectedTimes[index]);
this.inactiveTimes
.forEach((inactiveTime) => {
if (inactiveTime.position > index) {
inactiveTime.position -= 1;
}
});
selectedTimes.splice(index, 1);
if (selectedTimes.length === 1) {
this.inactiveTimes = [];
}
// Activate the first time if none is active.
if (selectedTimes.length === this.inactiveTimes.length) {
this.inactiveTimes.shift();
}
this.storeTimes(selectedTimes);
}
toggleActive(active:boolean, index:number, selectedTimes:string[]):void {
if (!active) {
this.inactiveTimes.push({ position: index, time: selectedTimes[index] });
} else {
this.inactiveTimes = this.inactiveTimes.filter((inactiveTime) => inactiveTime.time !== selectedTimes[index]);
}
this.storeTimes(selectedTimes);
}
timeLabel(time:string):string {
return this
.I18n
.toTime(
'time.formats.time',
ReminderSettingsDailyTimeComponent.dateForHour(parseInt(time.split(':')[0], 10)),
);
}
isDisabled(time:string, activeTimes:string[]):boolean {
return activeTimes.length === 0 || (activeTimes.length === 1 && activeTimes[0] === time);
}
private storeTimes(selectedTimes:string[]) {
const times = selectedTimes
.filter(
(selected) => !this.inactiveTimes
.map((inactive) => inactive.time)
.includes(selected),
);
const timesForm = this.form.get('times') as UntypedFormArray;
timesForm.clear({ emitEvent: false });
times.forEach((time) => {
timesForm.push(new UntypedFormControl(time), { emitEvent: false });
});
timesForm.enable({ emitEvent: true });
}
private firstAvailableSuggested(selectedTimes:string[]) {
return this.availableTimes.find((v) => this.suggestedTimes.includes(v) && !selectedTimes.includes(v));
}
private firstAfterSelected(selectedTimes:string[]) {
const indexLastSelected = this.availableTimes.indexOf(selectedTimes[selectedTimes.length - 1]);
for (let i = indexLastSelected; i < 24 + indexLastSelected; i++) {
if (!selectedTimes.includes(this.availableTimes[i % 24])) {
return this.availableTimes[i % 24];
}
}
return null;
}
private static setupAvailableTimes() {
return Array.from({ length: 24 }, (v, i) => ReminderSettingsDailyTimeComponent
.dateForHour(i)
.toLocaleTimeString('en-US', { hour12: false, hour: 'numeric', minute: 'numeric' }));
}
private static dateForHour(hour:number) {
const currentTime = new Date();
currentTime.setTime(1000 * 60 * 60 * (hour - 1));
const convertTimeObject = new Date(moment(currentTime).utc().hours(hour).format('YYYY-MM-DDTHH:mm:ss'));
return convertTimeObject;
}
}
@@ -1,21 +0,0 @@
<ng-container [formGroup]="formGroup.control">
<div class="op-form--section-header">
<h3 [textContent]="text.title" class="op-form--section-header-title"></h3>
</div>
@for (workday of localeWorkdays; track workday; let i = $index) {
<spot-selector-field
formArrayName="workdays"
[label]="workday"
[control]="controlForLocalWorkday(workday)"
>
<input
slot="input"
type="checkbox"
[formControlName]="indexOfLocalWorkday(workday)"
/>
</spot-selector-field>
}
<op-pause-reminders />
</ng-container>
@@ -1,70 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
OnInit,
} from '@angular/core';
import {
UntypedFormArray,
UntypedFormControl,
FormGroupDirective,
} from '@angular/forms';
import moment from 'moment';
import { I18nService } from 'core-app/core/i18n/i18n.service';
@Component({
selector: 'op-workdays-settings',
templateUrl: './workdays-settings.component.html',
styleUrls: ['./workdays-settings.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class WorkdaysSettingsComponent implements OnInit {
control:UntypedFormArray;
/**
* The locale might render workdays in a different order, which is what moment return with localeSorted
* and used for rendering the component.
*/
localeWorkdays:string[] = moment.weekdays(true);
/**
* Almost* ISO workdays with localized strings.
* ISO workdays are 1=Monday, ... 7=Sunday which is what we persist
*
* Working with the FormArray however, we use 0=Monday, 6=Sunday and add one before saving
* @private
*/
private isoWorkdays:string[] = WorkdaysSettingsComponent.buildISOWeekdays();
text = {
title: this.I18n.t('js.reminders.settings.workdays.title'),
};
constructor(
private I18n:I18nService,
readonly formGroup:FormGroupDirective,
) {
}
ngOnInit():void {
this.control = this.formGroup.control.get('workdays') as UntypedFormArray;
}
indexOfLocalWorkday(day:string):number {
return this.isoWorkdays.indexOf(day);
}
controlForLocalWorkday(day:string):UntypedFormControl {
const index = this.indexOfLocalWorkday(day);
return this.control.at(index) as UntypedFormControl;
}
/** Workdays from moment.js are in non-ISO order, that means Sunday=0, Saturday=6 */
static buildISOWeekdays():string[] {
const days = moment.weekdays(false);
days.push(days.shift()!);
return days;
}
}
@@ -15,18 +15,6 @@ import {
import {
NotificationSettingsTableComponent,
} from './notifications-settings/table/notification-settings-table.component';
import { ReminderSettingsPageComponent } from './reminder-settings/page/reminder-settings-page.component';
import {
ReminderSettingsDailyTimeComponent,
} from 'core-app/features/user-preferences/reminder-settings/reminder-time/reminder-settings-daily-time.component';
import {
ImmediateReminderSettingsComponent,
} from 'core-app/features/user-preferences/reminder-settings/immediate-reminders/immediate-reminder-settings.component';
import {
EmailAlertsSettingsComponent,
} from 'core-app/features/user-preferences/reminder-settings/email-alerts/email-alerts-settings.component';
import { WorkdaysSettingsComponent } from './reminder-settings/workdays/workdays-settings.component';
import { PauseRemindersComponent } from './reminder-settings/pause-reminders/pause-reminders.component';
import { OpenprojectEnterpriseModule } from 'core-app/features/enterprise/openproject-enterprise.module';
@NgModule({
@@ -37,12 +25,6 @@ import { OpenprojectEnterpriseModule } from 'core-app/features/enterprise/openpr
NotificationsSettingsPageComponent,
NotificationSettingInlineCreateComponent,
NotificationSettingsTableComponent,
ReminderSettingsPageComponent,
ReminderSettingsDailyTimeComponent,
ImmediateReminderSettingsComponent,
EmailAlertsSettingsComponent,
WorkdaysSettingsComponent,
PauseRemindersComponent,
],
imports: [
CommonModule,
+4 -2
View File
@@ -1,3 +1,5 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -105,13 +107,13 @@ module OpenProject
name: "notifications",
partial: "users/notifications",
path: ->(params) { edit_user_path(params[:user], tab: :notifications) },
label: :"js.notifications.settings.title"
label: :"my_account.notifications_and_email.tabs.notifications"
},
{
name: "reminders",
partial: "users/reminders",
path: ->(params) { edit_user_path(params[:user], tab: :reminders) },
label: :"js.reminders.settings.title"
label: :"my_account.notifications_and_email.tabs.email_reminders"
}
]
end