diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index 7cc1b0bf2..77900d826 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -6,6 +6,7 @@ import { AnalyticsState } from '@osf/features/project/analytics/store'; import { AccountSettingsState } from '@osf/features/settings/account-settings/store/account-settings.state'; import { AddonsState } from '@osf/features/settings/addons/store'; import { DeveloperAppsState } from '@osf/features/settings/developer-apps/store'; +import { NotificationSubscriptionState } from '@osf/features/settings/notifications/store'; import { ProfileSettingsState } from '@osf/features/settings/profile-settings/profile-settings.state'; import { TokensState } from '@osf/features/settings/tokens/store'; @@ -20,4 +21,5 @@ export const STATES = [ DeveloperAppsState, AccountSettingsState, AnalyticsState, + NotificationSubscriptionState, ]; diff --git a/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts b/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts deleted file mode 100644 index 4e1942c0f..000000000 --- a/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Education } from '@osf/features/settings/profile-settings/education/educations.entities'; -import { Employment } from '@osf/features/settings/profile-settings/employment/employment.entities'; -import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; - -export interface UserUS { - id: string; - type: string; - attributes: { - full_name: string; - given_name: string; - family_name: string; - email?: string; - employment: Employment[]; - education: Education[]; - middle_names?: string; - suffix?: string; - social: Social; - date_registered: string; - allow_indexing?: boolean; - }; - relationships: { - default_region: { - data: { - id: string; - }; - }; - }; - links: { - html: string; - profile_image: string; - iri: string; - }; -} diff --git a/src/app/core/services/mappers/users/users.mapper.ts b/src/app/core/services/mappers/users/users.mapper.ts deleted file mode 100644 index bea7973ae..000000000 --- a/src/app/core/services/mappers/users/users.mapper.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { UserUS } from '@core/services/json-api/underscore-entites/user/user-us.entity'; -import { User } from '@core/services/user/user.entity'; - -export function mapUserUStoUser(user: UserUS): User { - return { - id: user.id, - fullName: user.attributes.full_name, - givenName: user.attributes.given_name, - middleNames: user.attributes.middle_names, - suffix: user.attributes.suffix, - familyName: user.attributes.family_name, - email: user.attributes.email, - dateRegistered: new Date(user.attributes.date_registered), - link: user.links.html, - education: user.attributes.education, - employment: user.attributes.employment, - iri: user.links.iri, - social: user.attributes.social, - defaultRegionId: user.relationships?.default_region?.data?.id, - allowIndexing: user.attributes?.allow_indexing, - }; -} diff --git a/src/app/core/services/user/index.ts b/src/app/core/services/user/index.ts new file mode 100644 index 000000000..0b302dca6 --- /dev/null +++ b/src/app/core/services/user/index.ts @@ -0,0 +1,3 @@ +export * from './user.mapper'; +export * from './user.models'; +export * from './user.service'; diff --git a/src/app/core/services/user/user.entity.ts b/src/app/core/services/user/user.entity.ts deleted file mode 100644 index 701e1a52f..000000000 --- a/src/app/core/services/user/user.entity.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Education } from '@osf/features/settings/profile-settings/education/educations.entities'; -import { Employment } from '@osf/features/settings/profile-settings/employment/employment.entities'; -import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; - -export interface User { - id: string; - fullName: string; - givenName: string; - familyName: string; - email?: string; - middleNames?: string; - suffix?: string; - education: Education[]; - employment: Employment[]; - social: Social; - dateRegistered: Date; - link?: string; - iri?: string; - defaultRegionId: string; - allowIndexing: boolean | undefined; -} diff --git a/src/app/core/services/user/user.mapper.ts b/src/app/core/services/user/user.mapper.ts new file mode 100644 index 000000000..f89faea54 --- /dev/null +++ b/src/app/core/services/user/user.mapper.ts @@ -0,0 +1,49 @@ +import { + User, + UserGetResponse, + UserSettings, + UserSettingsGetResponse, + UserSettingsUpdateRequest, +} from '@core/services/user/user.models'; + +export class UserMapper { + static fromUserGetResponse(user: UserGetResponse): User { + return { + id: user.id, + fullName: user.attributes.full_name, + givenName: user.attributes.given_name, + middleNames: user.attributes.middle_names, + suffix: user.attributes.suffix, + familyName: user.attributes.family_name, + email: user.attributes.email, + dateRegistered: new Date(user.attributes.date_registered), + link: user.links.html, + education: user.attributes.education, + employment: user.attributes.employment, + iri: user.links.iri, + social: user.attributes.social, + defaultRegionId: user.relationships?.default_region?.data?.id, + allowIndexing: user.attributes?.allow_indexing, + }; + } + + static fromUserSettingsGetResponse(userSettingsResponse: UserSettingsGetResponse): UserSettings { + return { + subscribeOsfGeneralEmail: userSettingsResponse.attributes.subscribe_osf_general_email, + subscribeOsfHelpEmail: userSettingsResponse.attributes.subscribe_osf_help_email, + }; + } + + static toUpdateUserSettingsRequest(userId: string, userSettings: UserSettings): UserSettingsUpdateRequest { + return { + data: { + id: userId, + type: 'user_settings', + attributes: { + subscribe_osf_general_email: userSettings.subscribeOsfGeneralEmail, + subscribe_osf_help_email: userSettings.subscribeOsfHelpEmail, + }, + }, + }; + } +} diff --git a/src/app/core/services/user/user.models.ts b/src/app/core/services/user/user.models.ts new file mode 100644 index 000000000..7264cd532 --- /dev/null +++ b/src/app/core/services/user/user.models.ts @@ -0,0 +1,78 @@ +import { Education } from '@osf/features/settings/profile-settings/education/educations.entities'; +import { Employment } from '@osf/features/settings/profile-settings/employment/employment.entities'; +import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; + +//Domain models +export interface User { + id: string; + fullName: string; + givenName: string; + familyName: string; + email?: string; + middleNames?: string; + suffix?: string; + education: Education[]; + employment: Employment[]; + social: Social; + dateRegistered: Date; + link?: string; + iri?: string; + defaultRegionId: string; + allowIndexing: boolean | undefined; +} + +export interface UserSettings { + subscribeOsfGeneralEmail: boolean; + subscribeOsfHelpEmail: boolean; +} + +// API Request/Response Models +export interface UserGetResponse { + id: string; + type: string; + attributes: { + full_name: string; + given_name: string; + family_name: string; + email?: string; + employment: Employment[]; + education: Education[]; + middle_names?: string; + suffix?: string; + social: Social; + date_registered: string; + allow_indexing?: boolean; + }; + relationships: { + default_region: { + data: { + id: string; + }; + }; + }; + links: { + html: string; + profile_image: string; + iri: string; + }; +} + +export interface UserSettingsGetResponse { + id: string; + type: 'user_settings'; + attributes: { + subscribe_osf_general_email: boolean; + subscribe_osf_help_email: boolean; + }; +} + +export interface UserSettingsUpdateRequest { + data: { + id: string; + type: 'user_settings'; + attributes: { + subscribe_osf_general_email: boolean; + subscribe_osf_help_email: boolean; + }; + }; +} diff --git a/src/app/core/services/user/user.service.ts b/src/app/core/services/user/user.service.ts index e8c1ff1a9..4d33895d4 100644 --- a/src/app/core/services/user/user.service.ts +++ b/src/app/core/services/user/user.service.ts @@ -4,9 +4,8 @@ import { inject, Injectable } from '@angular/core'; import { JsonApiResponse } from '@core/services/json-api/json-api.entity'; import { JsonApiService } from '@core/services/json-api/json-api.service'; -import { UserUS } from '@core/services/json-api/underscore-entites/user/user-us.entity'; -import { mapUserUStoUser } from '@core/services/mappers/users/users.mapper'; -import { User } from '@core/services/user/user.entity'; +import { UserMapper } from '@core/services/user/user.mapper'; +import { User, UserGetResponse, UserSettings, UserSettingsGetResponse } from '@core/services/user/user.models'; @Injectable({ providedIn: 'root', @@ -17,7 +16,21 @@ export class UserService { getCurrentUser(): Observable { return this.jsonApiService - .get>(this.baseUrl + 'users/me') - .pipe(map((user) => mapUserUStoUser(user.data))); + .get>(this.baseUrl + 'users/me/') + .pipe(map((user) => UserMapper.fromUserGetResponse(user.data))); + } + + getCurrentUserSettings(): Observable { + return this.jsonApiService + .get>(this.baseUrl + 'users/me/settings/') + .pipe(map((response) => UserMapper.fromUserSettingsGetResponse(response.data))); + } + + updateUserSettings(userId: string, userSettings: UserSettings): Observable { + const request = UserMapper.toUpdateUserSettingsRequest(userId, userSettings); + + return this.jsonApiService + .patch(this.baseUrl + `users/${userId}/settings/`, request) + .pipe(map((response) => UserMapper.fromUserSettingsGetResponse(response))); } } diff --git a/src/app/core/store/user/index.ts b/src/app/core/store/user/index.ts index ee67c0c04..09a5e6d0e 100644 --- a/src/app/core/store/user/index.ts +++ b/src/app/core/store/user/index.ts @@ -1,3 +1,4 @@ export * from './user.actions'; -export * from './user.models'; +export * from './user.model'; +export * from './user.selectors'; export * from './user.state'; diff --git a/src/app/core/store/user/user.actions.ts b/src/app/core/store/user/user.actions.ts index 0e255ffd8..584af0fa8 100644 --- a/src/app/core/store/user/user.actions.ts +++ b/src/app/core/store/user/user.actions.ts @@ -1,4 +1,4 @@ -import { User } from '@core/services/user/user.entity'; +import { User, UserSettings } from '@core/services/user'; export class GetCurrentUser { static readonly type = '[User] Get Current User'; @@ -9,3 +9,16 @@ export class SetCurrentUser { constructor(public user: User) {} } + +export class GetCurrentUserSettings { + static readonly type = '[User] Get Current User Settings'; +} + +export class UpdateUserSettings { + static readonly type = '[User] Update User Settings'; + + constructor( + public userId: string, + public updatedUserSettings: UserSettings + ) {} +} diff --git a/src/app/core/store/user/user.model.ts b/src/app/core/store/user/user.model.ts new file mode 100644 index 000000000..c6df22f0c --- /dev/null +++ b/src/app/core/store/user/user.model.ts @@ -0,0 +1,7 @@ +import { User, UserSettings } from '@core/services/user'; +import { AsyncStateModel } from '@shared/models/store'; + +export interface UserStateModel { + currentUser: User | null; + currentUserSettings: AsyncStateModel; +} diff --git a/src/app/core/store/user/user.models.ts b/src/app/core/store/user/user.models.ts deleted file mode 100644 index 64e37b79c..000000000 --- a/src/app/core/store/user/user.models.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { User } from '@core/services/user/user.entity'; - -export interface UserStateModel { - currentUser: User | null; -} diff --git a/src/app/core/store/user/user.selectors.ts b/src/app/core/store/user/user.selectors.ts index 03acab6af..13b9231c5 100644 --- a/src/app/core/store/user/user.selectors.ts +++ b/src/app/core/store/user/user.selectors.ts @@ -1,8 +1,7 @@ import { Selector } from '@ngxs/store'; -import { User } from '@core/services/user/user.entity'; -import { UserStateModel } from '@core/store/user/user.models'; -import { UserState } from '@core/store/user/user.state'; +import { User, UserSettings } from '@core/services/user'; +import { UserState, UserStateModel } from '@core/store/user'; import { ProfileSettingsStateModel } from '@osf/features/settings/profile-settings/profile-settings.entities'; import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; @@ -29,4 +28,19 @@ export class UserSelectors { }, } satisfies ProfileSettingsStateModel; } + + @Selector([UserState]) + static getCurrentUserSettings(state: UserStateModel): UserSettings | null { + return state.currentUserSettings.data; + } + + @Selector([UserState]) + static isUserSettingsLoading(state: UserStateModel): boolean { + return state.currentUserSettings.isLoading; + } + + @Selector([UserState]) + static isUserSettingsSubmitting(state: UserStateModel): boolean { + return state.currentUserSettings.isSubmitting!; + } } diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts index 731e909a4..ff5dc51ce 100644 --- a/src/app/core/store/user/user.state.ts +++ b/src/app/core/store/user/user.state.ts @@ -1,4 +1,5 @@ import { Action, State, StateContext } from '@ngxs/store'; +import { patch } from '@ngxs/store/operators'; import { tap } from 'rxjs'; @@ -7,13 +8,19 @@ import { inject, Injectable } from '@angular/core'; import { UserService } from '@core/services/user/user.service'; import { SetupProfileSettings } from '@osf/features/settings/profile-settings/profile-settings.actions'; -import { GetCurrentUser, SetCurrentUser } from './user.actions'; -import { UserStateModel } from './user.models'; +import { GetCurrentUser, GetCurrentUserSettings, SetCurrentUser, UpdateUserSettings } from './user.actions'; +import { UserStateModel } from './user.model'; @State({ name: 'user', defaults: { currentUser: null, + currentUserSettings: { + data: null, + isLoading: false, + isSubmitting: false, + error: '', + }, }, }) @Injectable() @@ -24,7 +31,9 @@ export class UserState { getCurrentUser(ctx: StateContext) { return this.userService.getCurrentUser().pipe( tap((user) => { - ctx.dispatch(new SetCurrentUser(user)); + ctx.patchState({ + currentUser: user, + }); ctx.dispatch(new SetupProfileSettings()); }) ); @@ -36,4 +45,40 @@ export class UserState { currentUser: action.user, }); } + + @Action(GetCurrentUserSettings) + getCurrentUserSettings(ctx: StateContext) { + ctx.setState(patch({ currentUserSettings: patch({ isLoading: true }) })); + + return this.userService.getCurrentUserSettings().pipe( + tap((userSettings) => { + ctx.setState( + patch({ + currentUserSettings: patch({ + data: userSettings, + isLoading: false, + }), + }) + ); + }) + ); + } + + @Action(UpdateUserSettings) + updateUserSettings(ctx: StateContext, action: UpdateUserSettings) { + ctx.setState(patch({ currentUserSettings: patch({ isSubmitting: true }) })); + + return this.userService.updateUserSettings(action.userId, action.updatedUserSettings).pipe( + tap(() => { + ctx.setState( + patch({ + currentUserSettings: patch({ + data: action.updatedUserSettings, + isSubmitting: false, + }), + }) + ); + }) + ); + } } diff --git a/src/app/features/meetings/pages/meeting-details/meeting-details.component.html b/src/app/features/meetings/pages/meeting-details/meeting-details.component.html index 234762b69..783b22a80 100644 --- a/src/app/features/meetings/pages/meeting-details/meeting-details.component.html +++ b/src/app/features/meetings/pages/meeting-details/meeting-details.component.html @@ -9,6 +9,7 @@ /> (`${environment.apiUrl}/users/${this.#currentUser()?.id}`, body) - .pipe(map((user) => mapUserUStoUser(user))); + .patch(`${environment.apiUrl}/users/${this.#currentUser()?.id}`, body) + .pipe(map((user) => UserMapper.fromUserGetResponse(user))); } updateIndexing(allowIndexing: boolean): Observable { @@ -171,8 +169,8 @@ export class AccountSettingsService { }; return this.#jsonApiService - .patch(`${environment.apiUrl}/users/${this.#currentUser()?.id}`, body) - .pipe(map((user) => mapUserUStoUser(user))); + .patch(`${environment.apiUrl}/users/${this.#currentUser()?.id}`, body) + .pipe(map((user) => UserMapper.fromUserGetResponse(user))); } updatePassword(oldPassword: string, newPassword: string): Observable { diff --git a/src/app/features/settings/notifications/constants/index.ts b/src/app/features/settings/notifications/constants/index.ts new file mode 100644 index 000000000..b52eea378 --- /dev/null +++ b/src/app/features/settings/notifications/constants/index.ts @@ -0,0 +1 @@ +export * from './notifications-constants'; diff --git a/src/app/features/settings/notifications/constants/notifications-constants.ts b/src/app/features/settings/notifications/constants/notifications-constants.ts new file mode 100644 index 000000000..d20785817 --- /dev/null +++ b/src/app/features/settings/notifications/constants/notifications-constants.ts @@ -0,0 +1,27 @@ +import { SubscriptionEvent } from '@osf/features/settings/notifications/enums'; + +export const SUBSCRIPTION_EVENTS: { + event: SubscriptionEvent; + labelKey: string; +}[] = [ + { + event: SubscriptionEvent.GlobalCommentReplies, + labelKey: 'settings.notifications.notificationPreferences.items.replies', + }, + { + event: SubscriptionEvent.GlobalComments, + labelKey: 'settings.notifications.notificationPreferences.items.comments', + }, + { + event: SubscriptionEvent.GlobalFileUpdated, + labelKey: 'settings.notifications.notificationPreferences.items.files', + }, + { + event: SubscriptionEvent.GlobalMentions, + labelKey: 'settings.notifications.notificationPreferences.items.mentions', + }, + { + event: SubscriptionEvent.GlobalReviews, + labelKey: 'settings.notifications.notificationPreferences.items.preprints', + }, +]; diff --git a/src/app/features/settings/notifications/enums/index.ts b/src/app/features/settings/notifications/enums/index.ts new file mode 100644 index 000000000..655cab199 --- /dev/null +++ b/src/app/features/settings/notifications/enums/index.ts @@ -0,0 +1,2 @@ +export * from './subscription-event.enum'; +export * from './subscription-frequency.enum'; diff --git a/src/app/features/settings/notifications/enums/subscription-event.enum.ts b/src/app/features/settings/notifications/enums/subscription-event.enum.ts new file mode 100644 index 000000000..011f639a9 --- /dev/null +++ b/src/app/features/settings/notifications/enums/subscription-event.enum.ts @@ -0,0 +1,7 @@ +export enum SubscriptionEvent { + GlobalCommentReplies = 'global_comment_replies', + GlobalComments = 'global_comments', + GlobalFileUpdated = 'global_file_updated', + GlobalMentions = 'global_mentions', + GlobalReviews = 'global_reviews', +} diff --git a/src/app/features/settings/notifications/enums/subscription-frequency.enum.ts b/src/app/features/settings/notifications/enums/subscription-frequency.enum.ts new file mode 100644 index 000000000..2c9f96421 --- /dev/null +++ b/src/app/features/settings/notifications/enums/subscription-frequency.enum.ts @@ -0,0 +1,5 @@ +export enum SubscriptionFrequency { + Never = 'none', + Daily = 'daily', + Instant = 'instant', +} diff --git a/src/app/features/settings/notifications/mappers/index.ts b/src/app/features/settings/notifications/mappers/index.ts new file mode 100644 index 000000000..a10b45bdd --- /dev/null +++ b/src/app/features/settings/notifications/mappers/index.ts @@ -0,0 +1 @@ +export * from './notification-subscription.mapper'; diff --git a/src/app/features/settings/notifications/mappers/notification-subscription.mapper.ts b/src/app/features/settings/notifications/mappers/notification-subscription.mapper.ts new file mode 100644 index 000000000..202ecc431 --- /dev/null +++ b/src/app/features/settings/notifications/mappers/notification-subscription.mapper.ts @@ -0,0 +1,28 @@ +import { SubscriptionEvent, SubscriptionFrequency } from '@osf/features/settings/notifications/enums'; +import { + NotificationSubscription, + NotificationSubscriptionGetResponse, + NotificationSubscriptionUpdateRequest, +} from '@osf/features/settings/notifications/models'; + +export class NotificationSubscriptionMapper { + static fromGetResponse(response: NotificationSubscriptionGetResponse): NotificationSubscription { + return { + id: response.id, + event: response.attributes.event_name as SubscriptionEvent, + frequency: response.attributes.frequency as SubscriptionFrequency, + }; + } + + static toUpdateRequest(id: string, frequency: SubscriptionFrequency): NotificationSubscriptionUpdateRequest { + return { + data: { + id: id, + attributes: { + frequency: frequency, + }, + type: 'subscription', + }, + }; + } +} diff --git a/src/app/features/settings/notifications/models/index.ts b/src/app/features/settings/notifications/models/index.ts new file mode 100644 index 000000000..0f9926c54 --- /dev/null +++ b/src/app/features/settings/notifications/models/index.ts @@ -0,0 +1,2 @@ +export * from './notification-subscription.models'; +export * from './notifications-form.models'; diff --git a/src/app/features/settings/notifications/models/notification-subscription.models.ts b/src/app/features/settings/notifications/models/notification-subscription.models.ts new file mode 100644 index 000000000..65842edda --- /dev/null +++ b/src/app/features/settings/notifications/models/notification-subscription.models.ts @@ -0,0 +1,28 @@ +import { SubscriptionEvent, SubscriptionFrequency } from '@osf/features/settings/notifications/enums'; + +//domain models +export interface NotificationSubscription { + id: string; + event: SubscriptionEvent; + frequency: SubscriptionFrequency; +} + +//api models +export interface NotificationSubscriptionGetResponse { + id: string; + type: 'subscription'; + attributes: { + event_name: string; + frequency: string; + }; +} + +export interface NotificationSubscriptionUpdateRequest { + data: { + id: string; + type: 'subscription'; + attributes: { + frequency: SubscriptionFrequency; + }; + }; +} diff --git a/src/app/features/settings/notifications/models/notifications-form.models.ts b/src/app/features/settings/notifications/models/notifications-form.models.ts new file mode 100644 index 000000000..4d7df9899 --- /dev/null +++ b/src/app/features/settings/notifications/models/notifications-form.models.ts @@ -0,0 +1,11 @@ +import { FormControl, FormGroup } from '@angular/forms'; + +export enum EmailPreferencesFormControls { + SubscribeOsfGeneralEmail = 'subscribeOsfGeneralEmail', + SubscribeOsfHelpEmail = 'subscribeOsfHelpEmail', +} + +export type EmailPreferencesForm = FormGroup<{ + [EmailPreferencesFormControls.SubscribeOsfGeneralEmail]: FormControl; + [EmailPreferencesFormControls.SubscribeOsfHelpEmail]: FormControl; +}>; diff --git a/src/app/features/settings/notifications/notifications.component.html b/src/app/features/settings/notifications/notifications.component.html index 16908274f..9560aa354 100644 --- a/src/app/features/settings/notifications/notifications.component.html +++ b/src/app/features/settings/notifications/notifications.component.html @@ -1,83 +1,108 @@ -
-
-

{{ 'settings.notifications.emailPreferences.title' | translate }}

+
+ @if (!isEmailPreferencesLoading()) { +
+

{{ 'settings.notifications.emailPreferences.title' | translate }}

-
-
- +
+
+ -
- +
+ -
- {{ 'settings.notifications.emailPreferences.items.general.description' | translate }} +
+ {{ 'settings.notifications.emailPreferences.items.general.description' | translate }} +
-
-
- +
+ -
- +
+ -
- {{ 'settings.notifications.emailPreferences.items.help.description' | translate }} +
+ {{ 'settings.notifications.emailPreferences.items.help.description' | translate }} +
-
+
+ +
+ +
+ } @else { +
+ + +
+ @for (_ of [1, 2]; track $index) { +
+ + +
+ } +
- - +
-
+ } -
-

- {{ 'settings.notifications.notificationPreferences.title' | translate }} -

+ @if (!isNotificationSubscriptionsLoading()) { +
+

{{ 'settings.notifications.notificationPreferences.title' | translate }}

-
- {{ 'settings.notifications.notificationPreferences.note' | translate }} -
+

{{ 'settings.notifications.notificationPreferences.note' | translate }}

-
-
- {{ 'settings.notifications.notificationPreferences.items.replies' | translate }} -
- - -
- {{ 'settings.notifications.notificationPreferences.items.comments' | translate }} -
- - -
- {{ 'settings.notifications.notificationPreferences.items.files' | translate }} -
- - -
- {{ 'settings.notifications.notificationPreferences.items.mentions' | translate }} -
- +
+ @for (subscriptionEvent of SUBSCRIPTION_EVENTS; track $index) { +

{{ subscriptionEvent.labelKey | translate }}

+ + } +
+
+ } @else { +
+ + -
- {{ 'settings.notifications.notificationPreferences.items.preprints' | translate }} -
- +
+ @for (_ of [1, 2, 3, 4, 5]; track $index) { + + + } +
-
-
+ } + diff --git a/src/app/features/settings/notifications/notifications.component.scss b/src/app/features/settings/notifications/notifications.component.scss index 4e31a193a..b9e14d13c 100644 --- a/src/app/features/settings/notifications/notifications.component.scss +++ b/src/app/features/settings/notifications/notifications.component.scss @@ -17,7 +17,7 @@ color: var.$dark-blue-1; } - @media (max-width: 599.99px) { + @media (max-width: 576px) { padding: 12px; } } @@ -33,7 +33,7 @@ width: 50%; } - @media (max-width: 599.99px) { + @media (max-width: 576px) { grid-template-columns: 1fr; grid-template-rows: repeat(10, 1fr); grid-row-gap: 0; diff --git a/src/app/features/settings/notifications/notifications.component.spec.ts b/src/app/features/settings/notifications/notifications.component.spec.ts index 98d1d5f84..d74aed34c 100644 --- a/src/app/features/settings/notifications/notifications.component.spec.ts +++ b/src/app/features/settings/notifications/notifications.component.spec.ts @@ -1,8 +1,17 @@ -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { Store } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; import { MockPipe, MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; + +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { mockStore } from '@shared/mocks'; + import { NotificationsComponent } from './notifications.component'; describe('NotificationsComponent', () => { @@ -10,9 +19,22 @@ describe('NotificationsComponent', () => { let fixture: ComponentFixture; beforeEach(async () => { + const store = mockStore; + store.selectSignal.mockImplementation(() => { + return signal([]); + }); + store.dispatch.mockImplementation(() => { + return of(); + }); + await TestBed.configureTestingModule({ imports: [NotificationsComponent, MockPipe(TranslatePipe)], - providers: [MockProvider(TranslateService)], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + MockProvider(TranslatePipe), + MockProvider(Store, store), + ], }).compileComponents(); fixture = TestBed.createComponent(NotificationsComponent); diff --git a/src/app/features/settings/notifications/notifications.component.ts b/src/app/features/settings/notifications/notifications.component.ts index 24295259a..b84a7c133 100644 --- a/src/app/features/settings/notifications/notifications.component.ts +++ b/src/app/features/settings/notifications/notifications.component.ts @@ -1,20 +1,129 @@ +import { select, Store } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Checkbox } from 'primeng/checkbox'; -import { SelectModule } from 'primeng/select'; +import { Select } from 'primeng/select'; +import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, HostBinding, inject, OnInit, signal } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { UserSettings } from '@core/services/user'; +import { GetCurrentUserSettings, UpdateUserSettings, UserSelectors } from '@core/store/user'; +import { SUBSCRIPTION_EVENTS } from '@osf/features/settings/notifications/constants'; +import { SubscriptionEvent, SubscriptionFrequency } from '@osf/features/settings/notifications/enums'; +import { EmailPreferencesForm, EmailPreferencesFormControls } from '@osf/features/settings/notifications/models'; +import { + GetAllGlobalNotificationSubscriptions, + NotificationSubscriptionSelectors, + UpdateNotificationSubscription, +} from '@osf/features/settings/notifications/store'; import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component'; @Component({ selector: 'osf-notifications', - imports: [SubHeaderComponent, Checkbox, Button, SelectModule, TranslatePipe], + imports: [SubHeaderComponent, Checkbox, Button, TranslatePipe, ReactiveFormsModule, Skeleton, Select], templateUrl: './notifications.component.html', styleUrl: './notifications.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class NotificationsComponent { +export class NotificationsComponent implements OnInit { @HostBinding('class') classes = 'flex flex-1 flex-column'; + + private readonly store = inject(Store); + private readonly fb = inject(FormBuilder); + + private currentUser = select(UserSelectors.getCurrentUser); + private emailPreferences = select(UserSelectors.getCurrentUserSettings); + private notificationSubscriptions = select(NotificationSubscriptionSelectors.getAllGlobalNotificationSubscriptions); + + protected isEmailPreferencesLoading = select(UserSelectors.isUserSettingsLoading); + protected isSubmittingEmailPreferences = select(UserSelectors.isUserSettingsSubmitting); + + protected isNotificationSubscriptionsLoading = select(NotificationSubscriptionSelectors.isLoading); + protected loadingEvents = signal([]); + + protected EmailPreferencesFormControls = EmailPreferencesFormControls; + protected emailPreferencesForm: EmailPreferencesForm = new FormGroup({ + [EmailPreferencesFormControls.SubscribeOsfGeneralEmail]: this.fb.control(false, { nonNullable: true }), + [EmailPreferencesFormControls.SubscribeOsfHelpEmail]: this.fb.control(false, { nonNullable: true }), + }); + + protected readonly SUBSCRIPTION_EVENTS = SUBSCRIPTION_EVENTS; + protected subscriptionFrequencyOptions = Object.entries(SubscriptionFrequency).map(([key, value]) => ({ + label: key, + value, + })); + protected notificationSubscriptionsForm = this.fb.group( + SUBSCRIPTION_EVENTS.reduce( + (control, { event }) => { + control[event] = this.fb.control(SubscriptionFrequency.Never, { nonNullable: true }); + return control; + }, + {} as Record> + ) + ); + + constructor() { + effect(() => { + if (this.emailPreferences()) { + this.updateEmailPreferencesForm(); + } + }); + + effect(() => { + if (this.notificationSubscriptions()) { + this.updateNotificationSubscriptionsForm(); + } + }); + } + + ngOnInit(): void { + if (!this.notificationSubscriptions().length) { + this.store.dispatch(new GetAllGlobalNotificationSubscriptions()); + } + + if (!this.emailPreferences()) { + this.store.dispatch(new GetCurrentUserSettings()); + } + } + + emailPreferencesFormSubmit(): void { + if (!this.currentUser()) { + return; + } + + const formValue = this.emailPreferencesForm.value as UserSettings; + this.store.dispatch(new UpdateUserSettings(this.currentUser()!.id, formValue)); + } + + onSubscriptionChange(event: SubscriptionEvent, frequency: SubscriptionFrequency) { + const user = this.currentUser(); + if (!user) return; + const id = `${user.id}_${event}`; + + this.loadingEvents.update((list) => [...list, event]); + this.store.dispatch(new UpdateNotificationSubscription({ id, frequency })).subscribe({ + complete: () => { + this.loadingEvents.update((list) => list.filter((item) => item !== event)); + }, + }); + } + + private updateEmailPreferencesForm() { + this.emailPreferencesForm.patchValue({ + [EmailPreferencesFormControls.SubscribeOsfGeneralEmail]: this.emailPreferences()?.subscribeOsfGeneralEmail, + [EmailPreferencesFormControls.SubscribeOsfHelpEmail]: this.emailPreferences()?.subscribeOsfHelpEmail, + }); + } + + private updateNotificationSubscriptionsForm() { + const patch: Record = {}; + for (const sub of this.notificationSubscriptions()) { + patch[sub.event] = sub.frequency; + } + this.notificationSubscriptionsForm.patchValue(patch); + } } diff --git a/src/app/features/settings/notifications/services/index.ts b/src/app/features/settings/notifications/services/index.ts new file mode 100644 index 000000000..3796c7c94 --- /dev/null +++ b/src/app/features/settings/notifications/services/index.ts @@ -0,0 +1 @@ +export * from './notification-subscription.service'; diff --git a/src/app/features/settings/notifications/services/notification-subscription.service.ts b/src/app/features/settings/notifications/services/notification-subscription.service.ts new file mode 100644 index 000000000..1876d1ebc --- /dev/null +++ b/src/app/features/settings/notifications/services/notification-subscription.service.ts @@ -0,0 +1,42 @@ +import { map, Observable } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { JsonApiResponse } from '@core/services/json-api/json-api.entity'; +import { JsonApiService } from '@core/services/json-api/json-api.service'; +import { SubscriptionFrequency } from '@osf/features/settings/notifications/enums'; +import { NotificationSubscriptionMapper } from '@osf/features/settings/notifications/mappers'; +import { + NotificationSubscription, + NotificationSubscriptionGetResponse, +} from '@osf/features/settings/notifications/models'; + +@Injectable({ + providedIn: 'root', +}) +export class NotificationSubscriptionService { + jsonApiService = inject(JsonApiService); + baseUrl = 'https://api.staging4.osf.io/v2/subscriptions/'; + + getAllGlobalNotificationSubscriptions(): Observable { + const params: Record = { + 'filter[event_name]': 'global_reviews,global_comments,global_comment_replies,global_file_updated,global_mentions', + }; + + return this.jsonApiService + .get>(this.baseUrl, params) + .pipe( + map((responses) => { + return responses.data.map((response) => NotificationSubscriptionMapper.fromGetResponse(response)); + }) + ); + } + + updateSubscription(id: string, frequency: SubscriptionFrequency): Observable { + const request = NotificationSubscriptionMapper.toUpdateRequest(id, frequency); + + return this.jsonApiService + .patch(this.baseUrl + id + '/', request) + .pipe(map((response) => NotificationSubscriptionMapper.fromGetResponse(response))); + } +} diff --git a/src/app/features/settings/notifications/store/index.ts b/src/app/features/settings/notifications/store/index.ts new file mode 100644 index 000000000..c7a0d5e09 --- /dev/null +++ b/src/app/features/settings/notifications/store/index.ts @@ -0,0 +1,4 @@ +export * from './notification-subscription.actions'; +export * from './notification-subscription.model'; +export * from './notification-subscription.selectors'; +export * from './notification-subscription.state'; diff --git a/src/app/features/settings/notifications/store/notification-subscription.actions.ts b/src/app/features/settings/notifications/store/notification-subscription.actions.ts new file mode 100644 index 000000000..a90de60fe --- /dev/null +++ b/src/app/features/settings/notifications/store/notification-subscription.actions.ts @@ -0,0 +1,11 @@ +import { SubscriptionFrequency } from '@osf/features/settings/notifications/enums'; + +export class GetAllGlobalNotificationSubscriptions { + static readonly type = '[Notification Subscriptions] Get All Global'; +} + +export class UpdateNotificationSubscription { + static readonly type = '[Notification Subscriptions] Update'; + + constructor(public payload: { id: string; frequency: SubscriptionFrequency }) {} +} diff --git a/src/app/features/settings/notifications/store/notification-subscription.model.ts b/src/app/features/settings/notifications/store/notification-subscription.model.ts new file mode 100644 index 000000000..24487bbca --- /dev/null +++ b/src/app/features/settings/notifications/store/notification-subscription.model.ts @@ -0,0 +1,6 @@ +import { NotificationSubscription } from '@osf/features/settings/notifications/models'; +import { AsyncStateModel } from '@shared/models/store'; + +export interface NotificationSubscriptionModel { + notificationSubscriptions: AsyncStateModel; +} diff --git a/src/app/features/settings/notifications/store/notification-subscription.selectors.ts b/src/app/features/settings/notifications/store/notification-subscription.selectors.ts new file mode 100644 index 000000000..190a2e03b --- /dev/null +++ b/src/app/features/settings/notifications/store/notification-subscription.selectors.ts @@ -0,0 +1,19 @@ +import { Selector } from '@ngxs/store'; + +import { NotificationSubscription } from '@osf/features/settings/notifications/models'; +import { + NotificationSubscriptionModel, + NotificationSubscriptionState, +} from '@osf/features/settings/notifications/store'; + +export class NotificationSubscriptionSelectors { + @Selector([NotificationSubscriptionState]) + static getAllGlobalNotificationSubscriptions(state: NotificationSubscriptionModel): NotificationSubscription[] { + return state.notificationSubscriptions.data; + } + + @Selector([NotificationSubscriptionState]) + static isLoading(state: NotificationSubscriptionModel): boolean { + return state.notificationSubscriptions.isLoading; + } +} diff --git a/src/app/features/settings/notifications/store/notification-subscription.state.ts b/src/app/features/settings/notifications/store/notification-subscription.state.ts new file mode 100644 index 000000000..f9b6b342b --- /dev/null +++ b/src/app/features/settings/notifications/store/notification-subscription.state.ts @@ -0,0 +1,65 @@ +import { Action, State, StateContext } from '@ngxs/store'; +import { patch, updateItem } from '@ngxs/store/operators'; + +import { tap } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { NotificationSubscription } from '@osf/features/settings/notifications/models'; +import { NotificationSubscriptionService } from '@osf/features/settings/notifications/services'; +import { + GetAllGlobalNotificationSubscriptions, + UpdateNotificationSubscription, +} from '@osf/features/settings/notifications/store/notification-subscription.actions'; +import { NotificationSubscriptionModel } from '@osf/features/settings/notifications/store/notification-subscription.model'; + +@State({ + name: 'notificationSubscriptions', + defaults: { + notificationSubscriptions: { + data: [], + isLoading: false, + error: '', + }, + }, +}) +@Injectable() +export class NotificationSubscriptionState { + #notificationSubscriptionService = inject(NotificationSubscriptionService); + + @Action(GetAllGlobalNotificationSubscriptions) + getAllGlobalNotificationSubscriptions(ctx: StateContext) { + ctx.setState(patch({ notificationSubscriptions: patch({ isLoading: true }) })); + + return this.#notificationSubscriptionService.getAllGlobalNotificationSubscriptions().pipe( + tap((notificationSubscriptions) => { + ctx.setState( + patch({ + notificationSubscriptions: patch({ + data: notificationSubscriptions, + isLoading: false, + }), + }) + ); + }) + ); + } + + @Action(UpdateNotificationSubscription) + updateNotificationSubscription( + ctx: StateContext, + action: UpdateNotificationSubscription + ) { + return this.#notificationSubscriptionService.updateSubscription(action.payload.id, action.payload.frequency).pipe( + tap((updatedSubscription) => { + ctx.setState( + patch({ + notificationSubscriptions: patch({ + data: updateItem((app) => app.id === action.payload.id, updatedSubscription), + }), + }) + ); + }) + ); + } +} diff --git a/src/app/features/settings/profile-settings/name/name.entities.ts b/src/app/features/settings/profile-settings/name/name.entities.ts index 7a84ff44e..aef3295e5 100644 --- a/src/app/features/settings/profile-settings/name/name.entities.ts +++ b/src/app/features/settings/profile-settings/name/name.entities.ts @@ -1,6 +1,6 @@ import { FormControl } from '@angular/forms'; -import { User } from '@core/services/user/user.entity'; +import { User } from '@core/services/user/user.models'; export interface Name { fullName: string; diff --git a/src/app/features/settings/profile-settings/profile-settings.actions.ts b/src/app/features/settings/profile-settings/profile-settings.actions.ts index 9d53799be..9703bd206 100644 --- a/src/app/features/settings/profile-settings/profile-settings.actions.ts +++ b/src/app/features/settings/profile-settings/profile-settings.actions.ts @@ -1,4 +1,4 @@ -import { User } from '@core/services/user/user.entity'; +import { User } from '@core/services/user'; import { Education } from '@osf/features/settings/profile-settings/education/educations.entities'; import { Employment } from '@osf/features/settings/profile-settings/employment/employment.entities'; import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; diff --git a/src/app/features/settings/profile-settings/profile-settings.api.service.ts b/src/app/features/settings/profile-settings/profile-settings.api.service.ts index eef651d26..0643cdbdd 100644 --- a/src/app/features/settings/profile-settings/profile-settings.api.service.ts +++ b/src/app/features/settings/profile-settings/profile-settings.api.service.ts @@ -1,7 +1,7 @@ import { inject, Injectable } from '@angular/core'; import { JsonApiService } from '@core/services/json-api/json-api.service'; -import { UserUS } from '@core/services/json-api/underscore-entites/user/user-us.entity'; +import { UserGetResponse } from '@core/services/user'; import { JsonApiResponse } from '@osf/core/services/json-api/json-api.entity'; import { ProfileSettingsStateModel, @@ -17,7 +17,7 @@ export class ProfileSettingsApiService { patchUserSettings(userId: string, key: keyof ProfileSettingsStateModel, data: ProfileSettingsUpdate) { const patchedData = { [key]: data }; - return this.#jsonApiService.patch>(`${this.#baseUrl}users/${userId}/`, { + return this.#jsonApiService.patch>(`${this.#baseUrl}users/${userId}/`, { data: { type: 'users', id: userId, attributes: patchedData }, }); } diff --git a/src/app/features/settings/profile-settings/profile-settings.entities.ts b/src/app/features/settings/profile-settings/profile-settings.entities.ts index b24371ec5..29063b648 100644 --- a/src/app/features/settings/profile-settings/profile-settings.entities.ts +++ b/src/app/features/settings/profile-settings/profile-settings.entities.ts @@ -1,4 +1,4 @@ -import { User } from '@core/services/user/user.entity'; +import { User } from '@core/services/user'; import { Education } from '@osf/features/settings/profile-settings/education/educations.entities'; import { Employment } from '@osf/features/settings/profile-settings/employment/employment.entities'; import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; diff --git a/src/app/features/settings/profile-settings/profile-settings.selectors.ts b/src/app/features/settings/profile-settings/profile-settings.selectors.ts index 9093754ff..dd7377e87 100644 --- a/src/app/features/settings/profile-settings/profile-settings.selectors.ts +++ b/src/app/features/settings/profile-settings/profile-settings.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; -import { User } from '@core/services/user/user.entity'; +import { User } from '@core/services/user'; import { Education } from '@osf/features/settings/profile-settings/education/educations.entities'; import { Employment } from '@osf/features/settings/profile-settings/employment/employment.entities'; import { ProfileSettingsStateModel } from '@osf/features/settings/profile-settings/profile-settings.entities'; diff --git a/src/app/shared/models/store/async-state.model.ts b/src/app/shared/models/store/async-state.model.ts index acce4324e..8cdd5abf3 100644 --- a/src/app/shared/models/store/async-state.model.ts +++ b/src/app/shared/models/store/async-state.model.ts @@ -1,5 +1,6 @@ export interface AsyncStateModel { data: T; isLoading: boolean; + isSubmitting?: boolean; error: string | null; }