diff --git a/src/app/core/components/header/header.component.ts b/src/app/core/components/header/header.component.ts index bf0249e39..79c173170 100644 --- a/src/app/core/components/header/header.component.ts +++ b/src/app/core/components/header/header.component.ts @@ -28,7 +28,7 @@ export class HeaderComponent { label: 'navigation.myProfile', command: () => this.router.navigate(['my-profile']), }, - { label: 'navigation.settings', command: () => console.log('Settings') }, + { label: 'navigation.settings', command: () => this.router.navigate(['settings']) }, { label: 'navigation.logOut', command: () => console.log('Log out') }, ]; } diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index 5189ee730..8f026f40d 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -9,7 +9,6 @@ import { WikiState } from '@osf/features/project/wiki/store/wiki.state'; import { AccountSettingsState } from '@osf/features/settings/account-settings/store/account-settings.state'; 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/store/profile-settings.state'; import { AddonsState, InstitutionsState } from '@shared/stores'; import { LicensesState } from '@shared/stores/licenses'; import { RegionsState } from '@shared/stores/regions'; @@ -20,7 +19,6 @@ export const STATES = [ UserState, MyProjectsState, InstitutionsState, - ProfileSettingsState, DeveloperAppsState, AccountSettingsState, NotificationSubscriptionState, diff --git a/src/app/core/models/user.mapper.ts b/src/app/core/models/user.mapper.ts index a9e8f9d91..845ba0da0 100644 --- a/src/app/core/models/user.mapper.ts +++ b/src/app/core/models/user.mapper.ts @@ -1,6 +1,7 @@ import { User, UserGetResponse, + UserNamesJsonApi, UserSettings, UserSettingsGetResponse, UserSettingsUpdateRequest, @@ -46,4 +47,14 @@ export class UserMapper { }, }; } + + static toNamesRequest(name: Partial): UserNamesJsonApi { + return { + full_name: name.fullName ?? '', + given_name: name.givenName ?? '', + family_name: name.familyName ?? '', + middle_names: name.middleNames ?? '', + suffix: name.suffix ?? '', + }; + } } diff --git a/src/app/core/models/user.models.ts b/src/app/core/models/user.models.ts index 318400525..6495cac21 100644 --- a/src/app/core/models/user.models.ts +++ b/src/app/core/models/user.models.ts @@ -72,3 +72,11 @@ export interface UserSettingsUpdateRequest { }; }; } + +export interface UserNamesJsonApi { + full_name: string; + given_name: string; + family_name: string; + middle_names: string; + suffix: string; +} diff --git a/src/app/core/services/user.service.ts b/src/app/core/services/user.service.ts index 4daf67cf8..431f512ca 100644 --- a/src/app/core/services/user.service.ts +++ b/src/app/core/services/user.service.ts @@ -2,6 +2,9 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { ProfileSettingsKey } from '@osf/shared/enums'; +import { ProfileSettingsUpdate } from '@osf/shared/models'; + import { JsonApiResponse, User, UserGetResponse, UserMapper, UserSettings, UserSettingsGetResponse } from '../models'; import { JsonApiService } from './json-api.service'; @@ -33,4 +36,14 @@ export class UserService { .patch(`${environment.apiUrl}/users/${userId}/settings/`, request) .pipe(map((response) => UserMapper.fromUserSettingsGetResponse(response))); } + + updateUserProfile(userId: string, key: string, data: ProfileSettingsUpdate): Observable { + const patchedData = key === ProfileSettingsKey.User ? data : { [key]: data }; + + return this.jsonApiService + .patch(`${environment.apiUrl}/users/${userId}/`, { + data: { type: 'users', id: userId, attributes: patchedData }, + }) + .pipe(map((response) => UserMapper.fromUserGetResponse(response))); + } } diff --git a/src/app/core/store/user/user.actions.ts b/src/app/core/store/user/user.actions.ts index 397e344b0..2c6f64356 100644 --- a/src/app/core/store/user/user.actions.ts +++ b/src/app/core/store/user/user.actions.ts @@ -1,3 +1,5 @@ +import { Education, Employment, Social } from '@osf/shared/models'; + import { User, UserSettings } from '../../models'; export class GetCurrentUser { @@ -6,7 +8,6 @@ export class GetCurrentUser { export class SetCurrentUser { static readonly type = '[User] Set Current User'; - constructor(public user: User) {} } @@ -16,9 +17,32 @@ export class GetCurrentUserSettings { export class UpdateUserSettings { static readonly type = '[User] Update User Settings'; - constructor( public userId: string, public updatedUserSettings: UserSettings ) {} } + +export class UpdateProfileSettingsEmployment { + static readonly type = '[Profile Settings] Update Employment'; + + constructor(public payload: { employment: Employment[] }) {} +} + +export class UpdateProfileSettingsEducation { + static readonly type = '[Profile Settings] Update Education'; + + constructor(public payload: { education: Education[] }) {} +} + +export class UpdateProfileSettingsSocialLinks { + static readonly type = '[Profile Settings] Update Social Links'; + + constructor(public payload: { socialLinks: Partial[] }) {} +} + +export class UpdateProfileSettingsUser { + static readonly type = '[Profile Settings] Update User'; + + constructor(public payload: { user: Partial }) {} +} diff --git a/src/app/core/store/user/user.model.ts b/src/app/core/store/user/user.model.ts index fdb6aec8e..0042d4309 100644 --- a/src/app/core/store/user/user.model.ts +++ b/src/app/core/store/user/user.model.ts @@ -1,4 +1,4 @@ -import { AsyncStateModel } from '@shared/models/store'; +import { AsyncStateModel } from '@osf/shared/models'; import { User, UserSettings } from '../../models'; @@ -6,3 +6,17 @@ export interface UserStateModel { currentUser: AsyncStateModel; currentUserSettings: AsyncStateModel; } + +export const USER_STATE_INITIAL: UserStateModel = { + currentUser: { + data: null, + isLoading: false, + error: null, + }, + currentUserSettings: { + data: null, + isLoading: false, + isSubmitting: false, + error: '', + }, +}; diff --git a/src/app/core/store/user/user.selectors.ts b/src/app/core/store/user/user.selectors.ts index 00150c09f..4941d0c05 100644 --- a/src/app/core/store/user/user.selectors.ts +++ b/src/app/core/store/user/user.selectors.ts @@ -1,9 +1,10 @@ import { Selector } from '@ngxs/store'; -import { UserState, UserStateModel } from '@core/store/user'; import { User, UserSettings } from '@osf/core/models'; -import { ProfileSettingsStateModel } from '@osf/features/settings/profile-settings/store'; -import { Social } from '@osf/shared/models'; +import { Education, Employment, Social } from '@osf/shared/models'; + +import { UserStateModel } from './user.model'; +import { UserState } from './user.state'; export class UserSelectors { @Selector([UserState]) @@ -16,24 +17,6 @@ export class UserSelectors { return state.currentUser.isLoading; } - @Selector([UserState]) - static getProfileSettings(state: UserStateModel): ProfileSettingsStateModel { - return { - education: state.currentUser.data?.education ?? [], - employment: state.currentUser.data?.employment ?? [], - social: state.currentUser.data?.social ?? ({} as Social), - user: { - middleNames: state.currentUser.data?.middleNames ?? '', - suffix: state.currentUser.data?.suffix ?? '', - id: state.currentUser.data?.id ?? '', - fullName: state.currentUser.data?.fullName ?? '', - email: state.currentUser.data?.email ?? '', - givenName: state.currentUser.data?.givenName ?? '', - familyName: state.currentUser.data?.familyName ?? '', - }, - } satisfies ProfileSettingsStateModel; - } - @Selector([UserState]) static getCurrentUserSettings(state: UserStateModel): UserSettings | null { return state.currentUserSettings.data; @@ -53,4 +36,24 @@ export class UserSelectors { static getShareIndexing(state: UserStateModel): boolean | undefined { return state.currentUser.data?.allowIndexing; } + + @Selector([UserState]) + static getUserNames(state: UserStateModel): Partial | null { + return state.currentUser.data; + } + + @Selector([UserState]) + static getEmployment(state: UserStateModel): Employment[] { + return state.currentUser.data?.employment || []; + } + + @Selector([UserState]) + static getEducation(state: UserStateModel): Education[] { + return state.currentUser.data?.education || []; + } + + @Selector([UserState]) + static getSocialLinks(state: UserStateModel): Social | undefined { + return state.currentUser.data?.social; + } } diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts index be4743f2c..cd43fcb29 100644 --- a/src/app/core/store/user/user.state.ts +++ b/src/app/core/store/user/user.state.ts @@ -5,28 +5,28 @@ import { tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; -import { SetupProfileSettings } from '@osf/features/settings/profile-settings/store/profile-settings.actions'; +import { UserMapper } from '@osf/core/models'; +import { removeNullable } from '@osf/shared/constants'; +import { ProfileSettingsKey } from '@osf/shared/enums'; +import { Social } from '@osf/shared/models'; import { UserService } from '../../services'; -import { GetCurrentUser, GetCurrentUserSettings, SetCurrentUser, UpdateUserSettings } from './user.actions'; -import { UserStateModel } from './user.model'; +import { + GetCurrentUser, + GetCurrentUserSettings, + SetCurrentUser, + UpdateProfileSettingsEducation, + UpdateProfileSettingsEmployment, + UpdateProfileSettingsSocialLinks, + UpdateProfileSettingsUser, + UpdateUserSettings, +} from './user.actions'; +import { USER_STATE_INITIAL, UserStateModel } from './user.model'; @State({ name: 'user', - defaults: { - currentUser: { - data: null, - isLoading: false, - error: null, - }, - currentUserSettings: { - data: null, - isLoading: false, - isSubmitting: false, - error: '', - }, - }, + defaults: USER_STATE_INITIAL, }) @Injectable() export class UserState { @@ -50,7 +50,6 @@ export class UserState { error: null, }, }); - ctx.dispatch(new SetupProfileSettings()); }) ); } @@ -101,4 +100,103 @@ export class UserState { }) ); } + + @Action(UpdateProfileSettingsEmployment) + updateProfileSettingsEmployment(ctx: StateContext, { payload }: UpdateProfileSettingsEmployment) { + const state = ctx.getState(); + const userId = state.currentUser.data?.id; + + if (!userId) { + return; + } + + const withoutNulls = payload.employment.map((item) => removeNullable(item)); + + return this.userService.updateUserProfile(userId, ProfileSettingsKey.Employment, withoutNulls).pipe( + tap((user) => { + ctx.patchState({ + currentUser: { + ...state.currentUser, + data: user, + }, + }); + }) + ); + } + + @Action(UpdateProfileSettingsEducation) + updateProfileSettingsEducation(ctx: StateContext, { payload }: UpdateProfileSettingsEducation) { + const state = ctx.getState(); + const userId = state.currentUser.data?.id; + + if (!userId) { + return; + } + + const withoutNulls = payload.education.map((item) => removeNullable(item)); + + return this.userService.updateUserProfile(userId, ProfileSettingsKey.Education, withoutNulls).pipe( + tap((user) => { + ctx.patchState({ + currentUser: { + ...state.currentUser, + data: user, + }, + }); + }) + ); + } + + @Action(UpdateProfileSettingsUser) + updateProfileSettingsUser(ctx: StateContext, { payload }: UpdateProfileSettingsUser) { + const state = ctx.getState(); + const userId = state.currentUser.data?.id; + + if (!userId) { + return; + } + + const withoutNulls = UserMapper.toNamesRequest(removeNullable(payload.user)); + + return this.userService.updateUserProfile(userId, ProfileSettingsKey.User, withoutNulls).pipe( + tap((user) => { + ctx.patchState({ + currentUser: { + ...state.currentUser, + data: user, + }, + }); + }) + ); + } + + @Action(UpdateProfileSettingsSocialLinks) + updateProfileSettingsSocialLinks(ctx: StateContext, { payload }: UpdateProfileSettingsSocialLinks) { + const state = ctx.getState(); + const userId = state.currentUser.data?.id; + + if (!userId) { + return; + } + + let social = {} as Partial; + + payload.socialLinks.forEach((item) => { + social = { + ...social, + ...item, + }; + }); + + return this.userService.updateUserProfile(userId, ProfileSettingsKey.Social, social).pipe( + tap((user) => { + ctx.patchState({ + currentUser: { + ...state.currentUser, + data: user, + }, + }); + }) + ); + } } diff --git a/src/app/features/project/files/store/project-files.state.ts b/src/app/features/project/files/store/project-files.state.ts index 044e8d7a5..413079895 100644 --- a/src/app/features/project/files/store/project-files.state.ts +++ b/src/app/features/project/files/store/project-files.state.ts @@ -81,7 +81,6 @@ export class ProjectFilesState { error: null, }, }); - console.log('files is patched'); }, }), catchError((error) => this.handleError(ctx, 'files', error)) @@ -97,7 +96,6 @@ export class ProjectFilesState { @Action(SetCurrentFolder) setSelectedFolder(ctx: StateContext, action: SetCurrentFolder) { ctx.patchState({ currentFolder: action.folder }); - console.log('set currennt folder'); } @Action(SetMoveFileCurrentFolder) diff --git a/src/app/features/settings/account-settings/components/change-password/change-password.component.html b/src/app/features/settings/account-settings/components/change-password/change-password.component.html index 13e1747d8..29708f6c6 100644 --- a/src/app/features/settings/account-settings/components/change-password/change-password.component.html +++ b/src/app/features/settings/account-settings/components/change-password/change-password.component.html @@ -8,22 +8,18 @@

{{ 'settings.accountSettings.changePassword.title' | translate }}

- @if ( - passwordForm.get(AccountSettingsPasswordFormControls.OldPassword)?.errors?.['required'] && - passwordForm.get(AccountSettingsPasswordFormControls.OldPassword)?.touched - ) { - + + @if (FormValidationHelper.hasError(getFormControl(AccountSettingsPasswordFormControls.OldPassword), 'required')) { + {{ 'settings.accountSettings.changePassword.validation.oldPasswordRequired' | translate }} } @@ -34,70 +30,95 @@

{{ 'settings.accountSettings.changePassword.title' | translate }}

+ - - {{ 'settings.accountSettings.changePassword.form.passwordRequirements' | translate }} - + @if ( - passwordForm.get(AccountSettingsPasswordFormControls.NewPassword)?.errors?.['required'] && - passwordForm.get(AccountSettingsPasswordFormControls.NewPassword)?.touched + FormValidationHelper.hasError(getFormControl(AccountSettingsPasswordFormControls.NewPassword), 'required') ) { - + {{ 'settings.accountSettings.changePassword.validation.newPasswordRequired' | translate }} } + + @if ( + FormValidationHelper.hasError(getFormControl(AccountSettingsPasswordFormControls.NewPassword), 'minlength') + ) { + + {{ 'settings.accountSettings.changePassword.validation.newPasswordMinLength' | translate }} + + } + + @if ( + FormValidationHelper.hasError(getFormControl(AccountSettingsPasswordFormControls.NewPassword), 'pattern') + ) { + + {{ 'settings.accountSettings.changePassword.validation.newPasswordPattern' | translate }} + + } + @if ( - passwordForm.errors?.['sameAsOldPassword'] && - passwordForm.get(AccountSettingsPasswordFormControls.NewPassword)?.touched + getFormErrors()['sameAsOldPassword'] && + FormValidationHelper.isFieldTouched(getFormControl(AccountSettingsPasswordFormControls.NewPassword)) ) { - + {{ 'settings.accountSettings.changePassword.validation.sameAsOldPassword' | translate }} } + + + {{ 'settings.accountSettings.changePassword.form.passwordRequirements' | translate }} +
+ + @if ( - passwordForm.get(AccountSettingsPasswordFormControls.ConfirmPassword)?.errors?.['required'] && - passwordForm.get(AccountSettingsPasswordFormControls.ConfirmPassword)?.touched + FormValidationHelper.hasError(getFormControl(AccountSettingsPasswordFormControls.ConfirmPassword), 'required') ) { - + {{ 'settings.accountSettings.changePassword.validation.confirmPasswordRequired' | translate }} } + @if ( - passwordForm.errors?.['passwordMismatch'] && - passwordForm.get(AccountSettingsPasswordFormControls.ConfirmPassword)?.touched + getFormErrors()['passwordMismatch'] && + FormValidationHelper.isFieldTouched(getFormControl(AccountSettingsPasswordFormControls.ConfirmPassword)) ) { - + {{ 'settings.accountSettings.changePassword.validation.passwordsDoNotMatch' | translate }} } @@ -106,7 +127,7 @@

{{ 'settings.accountSettings.changePassword.title' | translate }}

@if (errorMessage()) { - {{ errorMessage() }} + {{ errorMessage() | translate }} }
diff --git a/src/app/features/settings/account-settings/components/change-password/change-password.component.ts b/src/app/features/settings/account-settings/components/change-password/change-password.component.ts index 1a25fb057..c4962114b 100644 --- a/src/app/features/settings/account-settings/components/change-password/change-password.component.ts +++ b/src/app/features/settings/account-settings/components/change-password/change-password.component.ts @@ -1,4 +1,6 @@ -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { createDispatchMap } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; @@ -6,7 +8,8 @@ import { Password } from 'primeng/password'; import { CommonModule } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; -import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AbstractControl, FormControl, @@ -16,8 +19,11 @@ import { Validators, } from '@angular/forms'; +import { LoaderService, ToastService } from '@osf/shared/services'; +import { CustomValidators, FormValidationHelper } from '@osf/shared/utils'; + import { AccountSettingsPasswordForm, AccountSettingsPasswordFormControls } from '../../models'; -import { AccountSettingsService } from '../../services'; +import { UpdatePassword } from '../../store'; @Component({ selector: 'osf-change-password', @@ -27,32 +33,40 @@ import { AccountSettingsService } from '../../services'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ChangePasswordComponent implements OnInit { - private readonly accountSettingsService = inject(AccountSettingsService); - private readonly translateService = inject(TranslateService); + private readonly actions = createDispatchMap({ updatePassword: UpdatePassword }); + private readonly loaderService = inject(LoaderService); + private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); readonly passwordForm: AccountSettingsPasswordForm = new FormGroup({ [AccountSettingsPasswordFormControls.OldPassword]: new FormControl('', { nonNullable: true, - validators: [Validators.required], + validators: [CustomValidators.requiredTrimmed()], }), [AccountSettingsPasswordFormControls.NewPassword]: new FormControl('', { nonNullable: true, validators: [ - Validators.required, + CustomValidators.requiredTrimmed(), Validators.minLength(8), Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[\d!@#$%^&*])[A-Za-z\d!@#$%^&*_]{8,}$/), ], }), [AccountSettingsPasswordFormControls.ConfirmPassword]: new FormControl('', { nonNullable: true, - validators: [Validators.required], + validators: [CustomValidators.requiredTrimmed()], }), }); protected readonly AccountSettingsPasswordFormControls = AccountSettingsPasswordFormControls; + protected readonly FormValidationHelper = FormValidationHelper; + protected errorMessage = signal(''); ngOnInit(): void { + this.setupFormValidation(); + } + + private setupFormValidation(): void { this.passwordForm.addValidators((control: AbstractControl): ValidationErrors | null => { const oldPassword = control.get(AccountSettingsPasswordFormControls.OldPassword)?.value; const newPassword = control.get(AccountSettingsPasswordFormControls.NewPassword)?.value; @@ -71,17 +85,38 @@ export class ChangePasswordComponent implements OnInit { return Object.keys(errors).length > 0 ? errors : null; }); - this.passwordForm.get(AccountSettingsPasswordFormControls.OldPassword)?.valueChanges.subscribe(() => { - this.passwordForm.updateValueAndValidity(); - }); + this.passwordForm + .get(AccountSettingsPasswordFormControls.OldPassword) + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.passwordForm.updateValueAndValidity()); - this.passwordForm.get(AccountSettingsPasswordFormControls.NewPassword)?.valueChanges.subscribe(() => { - this.passwordForm.updateValueAndValidity(); - }); + this.passwordForm + .get(AccountSettingsPasswordFormControls.NewPassword) + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.passwordForm.updateValueAndValidity()); - this.passwordForm.get(AccountSettingsPasswordFormControls.ConfirmPassword)?.valueChanges.subscribe(() => { - this.passwordForm.updateValueAndValidity(); - }); + this.passwordForm + .get(AccountSettingsPasswordFormControls.ConfirmPassword) + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.passwordForm.updateValueAndValidity()); + } + + protected getFormControl(controlName: string): AbstractControl | null { + return FormValidationHelper.getFormControl(this.passwordForm, controlName); + } + + protected getFormErrors(): Record { + const errors: Record = {}; + + if (this.passwordForm.errors?.['sameAsOldPassword']) { + errors['sameAsOldPassword'] = true; + } + + if (this.passwordForm.errors?.['passwordMismatch']) { + errors['passwordMismatch'] = true; + } + + return errors; } changePassword() { @@ -91,24 +126,30 @@ export class ChangePasswordComponent implements OnInit { if (this.passwordForm.valid) { this.errorMessage.set(''); + const oldPassword = this.passwordForm.get(AccountSettingsPasswordFormControls.OldPassword)?.value ?? ''; const newPassword = this.passwordForm.get(AccountSettingsPasswordFormControls.NewPassword)?.value ?? ''; - this.accountSettingsService.updatePassword(oldPassword, newPassword).subscribe({ + this.loaderService.show(); + + this.actions.updatePassword(oldPassword, newPassword).subscribe({ next: () => { this.passwordForm.reset(); Object.values(this.passwordForm.controls).forEach((control) => { control.markAsUntouched(); }); + + this.loaderService.hide(); + this.toastService.showSuccess('settings.accountSettings.changePassword.messages.success'); }, error: (error: HttpErrorResponse) => { if (error.error?.errors?.[0]?.detail) { this.errorMessage.set(error.error.errors[0].detail); } else { - this.errorMessage.set( - this.translateService.instant('settings.accountSettings.changePassword.messages.error') - ); + this.errorMessage.set('settings.accountSettings.changePassword.messages.error'); } + + this.loaderService.hide(); }, }); } diff --git a/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.html b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.html index 3b87b5728..211da1c76 100644 --- a/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.html +++ b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.html @@ -23,7 +23,7 @@

{{ 'settings.accountSettings.connectedEmails.title' | translate }}

{{ 'settings.accountSettings.connectedEmails.alternateEmails' | translate }}

-
+
@if (isEmailsLoading()) { } @else { @@ -51,7 +51,7 @@

{{ 'settings.accountSettings.connectedEmails.title' | translate }}

{{ 'settings.accountSettings.connectedEmails.unconfirmedEmails' | translate }}

-
+
@if (isEmailsLoading()) { } @else { diff --git a/src/app/features/settings/account-settings/store/account-settings.actions.ts b/src/app/features/settings/account-settings/store/account-settings.actions.ts index 3e38dae33..889a75203 100644 --- a/src/app/features/settings/account-settings/store/account-settings.actions.ts +++ b/src/app/features/settings/account-settings/store/account-settings.actions.ts @@ -108,3 +108,12 @@ export class DeactivateAccount { export class CancelDeactivationRequest { static readonly type = '[AccountSettings] Cancel Deactivation Request'; } + +export class UpdatePassword { + static readonly type = '[AccountSettings] Update Password'; + + constructor( + public oldPassword: string, + public newPassword: string + ) {} +} diff --git a/src/app/features/settings/account-settings/store/account-settings.state.ts b/src/app/features/settings/account-settings/store/account-settings.state.ts index f5915fd57..3b995169b 100644 --- a/src/app/features/settings/account-settings/store/account-settings.state.ts +++ b/src/app/features/settings/account-settings/store/account-settings.state.ts @@ -28,6 +28,7 @@ import { ResendConfirmation, UpdateAccountSettings, UpdateIndexing, + UpdatePassword, UpdateRegion, VerifyEmail, VerifyTwoFactorAuth, @@ -251,4 +252,11 @@ export class AccountSettingsState { catchError((error) => throwError(() => error)) ); } + + @Action(UpdatePassword) + updatePassword(ctx: StateContext, action: UpdatePassword) { + return this.accountSettingsService + .updatePassword(action.oldPassword, action.newPassword) + .pipe(catchError((error) => throwError(() => error))); + } } diff --git a/src/app/features/settings/notifications/notifications.component.html b/src/app/features/settings/notifications/notifications.component.html index 60d36ec11..861c9b082 100644 --- a/src/app/features/settings/notifications/notifications.component.html +++ b/src/app/features/settings/notifications/notifications.component.html @@ -2,7 +2,7 @@
@if (!isEmailPreferencesLoading()) { -
+

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

@@ -14,13 +14,13 @@

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

/>
-
@@ -32,13 +32,13 @@

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

/>
-
@@ -46,14 +46,14 @@

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

} @else { -
+
@@ -71,7 +71,7 @@

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

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

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

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

@@ -84,7 +84,6 @@

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

diff --git a/src/app/features/settings/notifications/notifications.component.scss b/src/app/features/settings/notifications/notifications.component.scss index 97ce219e6..428620beb 100644 --- a/src/app/features/settings/notifications/notifications.component.scss +++ b/src/app/features/settings/notifications/notifications.component.scss @@ -1,25 +1,8 @@ @use "assets/styles/variables" as var; -:host { - color: var.$dark-blue-1; -} - .notification-item { - padding: 24px; - border: 1px solid var.$grey-2; + border: 1px solid var(--grey-2); border-radius: 8px; - - &-label { - color: var.$dark-blue-1; - } - - &-description { - color: var.$dark-blue-1; - } - - @media (max-width: var.$breakpoint-sm) { - padding: 12px; - } } .notification-configuration { diff --git a/src/app/features/settings/notifications/notifications.component.ts b/src/app/features/settings/notifications/notifications.component.ts index 2a6bfdd9d..288956001 100644 --- a/src/app/features/settings/notifications/notifications.component.ts +++ b/src/app/features/settings/notifications/notifications.component.ts @@ -7,12 +7,13 @@ import { Checkbox } from 'primeng/checkbox'; import { Select } from 'primeng/select'; import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, effect, HostBinding, inject, OnInit, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, HostBinding, inject, OnInit } from '@angular/core'; import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { UserSettings } from '@osf/core/models'; import { GetCurrentUserSettings, UpdateUserSettings, UserSelectors } from '@osf/core/store/user'; import { SubHeaderComponent } from '@osf/shared/components'; +import { LoaderService, ToastService } from '@osf/shared/services'; import { SubscriptionEvent, SubscriptionFrequency } from '@shared/enums'; import { SUBSCRIPTION_EVENTS } from './constants'; @@ -40,6 +41,8 @@ export class NotificationsComponent implements OnInit { updateNotificationSubscription: UpdateNotificationSubscription, }); private readonly fb = inject(FormBuilder); + private readonly toastService = inject(ToastService); + private readonly loaderService = inject(LoaderService); private currentUser = select(UserSelectors.getCurrentUser); private emailPreferences = select(UserSelectors.getCurrentUserSettings); @@ -49,7 +52,6 @@ export class NotificationsComponent implements OnInit { protected isSubmittingEmailPreferences = select(UserSelectors.isUserSettingsSubmitting); protected isNotificationSubscriptionsLoading = select(NotificationSubscriptionSelectors.isLoading); - protected loadingEvents = signal([]); protected EmailPreferencesFormControls = EmailPreferencesFormControls; protected emailPreferencesForm: EmailPreferencesForm = new FormGroup({ @@ -102,7 +104,12 @@ export class NotificationsComponent implements OnInit { } const formValue = this.emailPreferencesForm.value as UserSettings; - this.actions.updateUserSettings(this.currentUser()!.id, formValue); + + this.loaderService.show(); + this.actions.updateUserSettings(this.currentUser()!.id, formValue).subscribe(() => { + this.loaderService.hide(); + this.toastService.showSuccess('settings.notifications.emailPreferences.successUpdate'); + }); } onSubscriptionChange(event: SubscriptionEvent, frequency: SubscriptionFrequency) { @@ -110,10 +117,11 @@ export class NotificationsComponent implements OnInit { if (!user) return; const id = `${user.id}_${event}`; - this.loadingEvents.update((list) => [...list, event]); + this.loaderService.show(); this.actions.updateNotificationSubscription({ id, frequency }).subscribe({ complete: () => { - this.loadingEvents.update((list) => list.filter((item) => item !== event)); + this.loaderService.hide(); + this.toastService.showSuccess('settings.notifications.notificationPreferences.successUpdate'); }, }); } diff --git a/src/app/features/settings/notifications/store/notification-subscription.model.ts b/src/app/features/settings/notifications/store/notification-subscription.model.ts index 963cebf66..129edd72f 100644 --- a/src/app/features/settings/notifications/store/notification-subscription.model.ts +++ b/src/app/features/settings/notifications/store/notification-subscription.model.ts @@ -2,7 +2,20 @@ import { AsyncStateModel } from '@osf/shared/models'; import { NotificationSubscription } from '../models'; -export interface NotificationSubscriptionModel { +export interface NotificationSubscriptionStateModel { notificationSubscriptions: AsyncStateModel; notificationSubscriptionsByNodeId: AsyncStateModel; } + +export const NOTIFICATION_SUBSCRIPTION_STATE_DEFAULTS: NotificationSubscriptionStateModel = { + notificationSubscriptions: { + data: [], + isLoading: false, + error: '', + }, + notificationSubscriptionsByNodeId: { + data: [], + isLoading: false, + error: '', + }, +}; diff --git a/src/app/features/settings/notifications/store/notification-subscription.selectors.ts b/src/app/features/settings/notifications/store/notification-subscription.selectors.ts index 0a9f7454f..629d72796 100644 --- a/src/app/features/settings/notifications/store/notification-subscription.selectors.ts +++ b/src/app/features/settings/notifications/store/notification-subscription.selectors.ts @@ -2,22 +2,22 @@ import { Selector } from '@ngxs/store'; import { NotificationSubscription } from '../models'; -import { NotificationSubscriptionModel } from './notification-subscription.model'; +import { NotificationSubscriptionStateModel } from './notification-subscription.model'; import { NotificationSubscriptionState } from './notification-subscription.state'; export class NotificationSubscriptionSelectors { @Selector([NotificationSubscriptionState]) - static getAllGlobalNotificationSubscriptions(state: NotificationSubscriptionModel): NotificationSubscription[] { + static getAllGlobalNotificationSubscriptions(state: NotificationSubscriptionStateModel): NotificationSubscription[] { return state.notificationSubscriptions.data; } @Selector([NotificationSubscriptionState]) - static getNotificationSubscriptionsByNodeId(state: NotificationSubscriptionModel): NotificationSubscription[] { + static getNotificationSubscriptionsByNodeId(state: NotificationSubscriptionStateModel): NotificationSubscription[] { return state.notificationSubscriptionsByNodeId.data; } @Selector([NotificationSubscriptionState]) - static isLoading(state: NotificationSubscriptionModel): boolean { + static isLoading(state: NotificationSubscriptionStateModel): 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 index 02c4fcf33..b9543a203 100644 --- a/src/app/features/settings/notifications/store/notification-subscription.state.ts +++ b/src/app/features/settings/notifications/store/notification-subscription.state.ts @@ -1,10 +1,12 @@ import { Action, State, StateContext } from '@ngxs/store'; import { patch, updateItem } from '@ngxs/store/operators'; -import { tap } from 'rxjs'; +import { catchError, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { handleSectionError } from '@osf/core/handlers'; + import { NotificationSubscription } from '../models'; import { NotificationSubscriptionService } from '../services'; @@ -14,29 +16,21 @@ import { UpdateNotificationSubscription, UpdateNotificationSubscriptionForNodeId, } from './notification-subscription.actions'; -import { NotificationSubscriptionModel } from './notification-subscription.model'; +import { + NOTIFICATION_SUBSCRIPTION_STATE_DEFAULTS, + NotificationSubscriptionStateModel, +} from './notification-subscription.model'; -@State({ +@State({ name: 'notificationSubscriptions', - defaults: { - notificationSubscriptions: { - data: [], - isLoading: false, - error: '', - }, - notificationSubscriptionsByNodeId: { - data: [], - isLoading: false, - error: '', - }, - }, + defaults: NOTIFICATION_SUBSCRIPTION_STATE_DEFAULTS, }) @Injectable() export class NotificationSubscriptionState { private readonly notificationSubscriptionService = inject(NotificationSubscriptionService); @Action(GetAllGlobalNotificationSubscriptions) - getAllGlobalNotificationSubscriptions(ctx: StateContext) { + getAllGlobalNotificationSubscriptions(ctx: StateContext) { ctx.setState(patch({ notificationSubscriptions: patch({ isLoading: true }) })); return this.notificationSubscriptionService.getAllGlobalNotificationSubscriptions().pipe( @@ -49,13 +43,14 @@ export class NotificationSubscriptionState { }), }) ); - }) + }), + catchError((error) => handleSectionError(ctx, 'notificationSubscriptions', error)) ); } @Action(GetNotificationSubscriptionsByNodeId) getNotificationSubscriptionsByNodeId( - ctx: StateContext, + ctx: StateContext, action: GetNotificationSubscriptionsByNodeId ) { return this.notificationSubscriptionService.getAllGlobalNotificationSubscriptions(action.nodeId).pipe( @@ -68,13 +63,14 @@ export class NotificationSubscriptionState { }), }) ); - }) + }), + catchError((error) => handleSectionError(ctx, 'notificationSubscriptionsByNodeId', error)) ); } @Action(UpdateNotificationSubscription) updateNotificationSubscription( - ctx: StateContext, + ctx: StateContext, action: UpdateNotificationSubscription ) { return this.notificationSubscriptionService.updateSubscription(action.payload.id, action.payload.frequency).pipe( @@ -88,13 +84,14 @@ export class NotificationSubscriptionState { }), }) ); - }) + }), + catchError((error) => handleSectionError(ctx, 'notificationSubscriptions', error)) ); } @Action(UpdateNotificationSubscriptionForNodeId) updateNotificationSubscriptionForNodeId( - ctx: StateContext, + ctx: StateContext, action: UpdateNotificationSubscription ) { return this.notificationSubscriptionService @@ -110,7 +107,8 @@ export class NotificationSubscriptionState { }), }) ); - }) + }), + catchError((error) => handleSectionError(ctx, 'notificationSubscriptionsByNodeId', error)) ); } } diff --git a/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.html b/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.html new file mode 100644 index 000000000..87c0c14f0 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.html @@ -0,0 +1,42 @@ +
+
+

+ {{ 'settings.profileSettings.name.citationPreview.title' | translate }} +

+ +
+
+
+

+ {{ 'settings.profileSettings.name.citationPreview.style' | translate }} +

+

+ {{ 'settings.profileSettings.name.citationPreview.citationFormat' | translate }} +

+
+ +
+

{{ 'settings.profileSettings.name.citationPreview.apa' | translate }}

+

{{ currentUser() | citationFormat: 'apa' }}

+
+
+ +
+
+

+ {{ 'settings.profileSettings.name.citationPreview.style' | translate }} +

+ +

+ {{ 'settings.profileSettings.name.citationPreview.citationFormat' | translate }} +

+
+ +
+

{{ 'settings.profileSettings.name.citationPreview.mla' | translate }}

+

{{ currentUser() | citationFormat: 'mla' }}

+
+
+
+
+
diff --git a/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.scss b/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.scss new file mode 100644 index 000000000..33318f598 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.scss @@ -0,0 +1,3 @@ +.name-container { + border: 1px solid var(--grey-2); +} diff --git a/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.spec.ts b/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.spec.ts new file mode 100644 index 000000000..f9924b069 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CitationPreviewComponent } from './citation-preview.component'; + +describe('CitationPreviewComponent', () => { + let component: CitationPreviewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CitationPreviewComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CitationPreviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.ts b/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.ts new file mode 100644 index 000000000..c70e94761 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.ts @@ -0,0 +1,17 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { User } from '@osf/core/models'; +import { CitationFormatPipe } from '@osf/shared/pipes'; + +@Component({ + selector: 'osf-citation-preview', + imports: [TranslatePipe, CitationFormatPipe], + templateUrl: './citation-preview.component.html', + styleUrl: './citation-preview.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CitationPreviewComponent { + currentUser = input.required>(); +} diff --git a/src/app/features/settings/profile-settings/components/education-form/education-form.component.html b/src/app/features/settings/profile-settings/components/education-form/education-form.component.html new file mode 100644 index 000000000..c26013141 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/education-form/education-form.component.html @@ -0,0 +1,101 @@ +
+
+

+ {{ 'settings.profileSettings.education.title' | translate: { index: index() + 1 } }} +

+ + @if (index() !== 0) { +
+ +
+ } +
+ +
+
+ +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+
+ + + +
+ + @if (!group().controls['ongoing'].value) { +
+ + + +
+ } +
+ +
+ + + +
+
+ + @if (isDateError) { + + {{ 'settings.profileSettings.endDateError' | translate }} + + } +
+
diff --git a/src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.html b/src/app/features/settings/profile-settings/components/education-form/education-form.component.scss similarity index 100% rename from src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.html rename to src/app/features/settings/profile-settings/components/education-form/education-form.component.scss diff --git a/src/app/features/settings/profile-settings/components/education-form/education-form.component.spec.ts b/src/app/features/settings/profile-settings/components/education-form/education-form.component.spec.ts new file mode 100644 index 000000000..5d56b15d4 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/education-form/education-form.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EducationFormComponent } from './education-form.component'; + +describe('EducationFormComponent', () => { + let component: EducationFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EducationFormComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(EducationFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/settings/profile-settings/components/education-form/education-form.component.ts b/src/app/features/settings/profile-settings/components/education-form/education-form.component.ts new file mode 100644 index 000000000..2f6ea4892 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/education-form/education-form.component.ts @@ -0,0 +1,56 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Checkbox } from 'primeng/checkbox'; +import { DatePicker } from 'primeng/datepicker'; +import { InputText } from 'primeng/inputtext'; +import { Message } from 'primeng/message'; + +import { filter } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, OnInit, output } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; + +import { TextInputComponent } from '@osf/shared/components'; +import { InputLimits } from '@osf/shared/constants'; + +import { MAX_DATE, MIN_DATE } from '../../constants'; + +@Component({ + selector: 'osf-education-form', + imports: [ReactiveFormsModule, Button, InputText, DatePicker, Checkbox, Message, TranslatePipe, TextInputComponent], + templateUrl: './education-form.component.html', + styleUrl: './education-form.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EducationFormComponent implements OnInit { + readonly maxDate = MAX_DATE; + readonly minDate = MIN_DATE; + readonly institutionMaxLength = InputLimits.fullName.maxLength; + readonly dateFormat = 'mm/dd/yy'; + + private readonly destroyRef = inject(DestroyRef); + + group = input.required(); + index = input.required(); + remove = output(); + + get institutionControl() { + return this.group().controls['institution'] as FormControl; + } + + get isDateError() { + const form = this.group(); + return form.errors && form.errors['dateRangeInvalid']; + } + + ngOnInit() { + this.group() + .controls['ongoing'].valueChanges.pipe( + filter((res) => !!res), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.group().controls['endDate'].setValue(null)); + } +} diff --git a/src/app/features/settings/profile-settings/components/education/education.component.html b/src/app/features/settings/profile-settings/components/education/education.component.html index fa1e4da90..e488f2233 100644 --- a/src/app/features/settings/profile-settings/components/education/education.component.html +++ b/src/app/features/settings/profile-settings/components/education/education.component.html @@ -1,93 +1,8 @@ -@let haveEducations = educationItems() && educationItems().length > 0;
- @if (haveEducations) { - @for (education of educations.controls; track education.value; let index = $index) { -
-
-

- {{ 'settings.profileSettings.education.title' | translate: { index: index + 1 } }} -

- @if (index !== 0) { -
- - -
- } -
- -
-
- - -
- -
-
- - -
-
- - -
-
- -
-
-
- - - -
-
- - -
-
- -
- - -
-
-
-
+ @if (educations.controls.length) { + @for (education of educations.controls; track $index; let index = $index) { + } }
@@ -103,11 +18,14 @@

+ -
- -
diff --git a/src/app/features/settings/profile-settings/components/education/education.component.ts b/src/app/features/settings/profile-settings/components/education/education.component.ts index 1fe57a832..bab83d158 100644 --- a/src/app/features/settings/profile-settings/components/education/education.component.ts +++ b/src/app/features/settings/profile-settings/components/education/education.component.ts @@ -3,21 +3,30 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; -import { Checkbox } from 'primeng/checkbox'; -import { DatePicker } from 'primeng/datepicker'; -import { InputText } from 'primeng/inputtext'; - -import { ChangeDetectionStrategy, Component, effect, HostBinding, inject } from '@angular/core'; -import { FormArray, FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + effect, + HostBinding, + inject, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; + +import { UpdateProfileSettingsEducation, UserSelectors } from '@osf/core/store/user'; import { Education } from '@osf/shared/models'; +import { CustomConfirmationService, LoaderService, ToastService } from '@osf/shared/services'; +import { CustomValidators, findChangedFields } from '@osf/shared/utils'; import { EducationForm } from '../../models'; -import { ProfileSettingsSelectors, UpdateProfileSettingsEducation } from '../../store'; +import { EducationFormComponent } from '../education-form/education-form.component'; @Component({ selector: 'osf-education', - imports: [ReactiveFormsModule, Button, InputText, DatePicker, Checkbox, TranslatePipe], + imports: [ReactiveFormsModule, Button, TranslatePipe, EducationFormComponent], templateUrl: './education.component.html', styleUrl: './education.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -25,38 +34,23 @@ import { ProfileSettingsSelectors, UpdateProfileSettingsEducation } from '../../ export class EducationComponent { @HostBinding('class') classes = 'flex flex-column gap-5'; - readonly fb = inject(FormBuilder); - protected readonly educationForm = this.fb.group({ educations: this.fb.array([]) }); + private readonly loaderService = inject(LoaderService); + private readonly customConfirmationService = inject(CustomConfirmationService); + private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + private readonly cd = inject(ChangeDetectorRef); + private readonly fb = inject(FormBuilder); + readonly educationForm = this.fb.group({ educations: this.fb.array([]) }); readonly actions = createDispatchMap({ updateProfileSettingsEducation: UpdateProfileSettingsEducation }); - readonly educationItems = select(ProfileSettingsSelectors.educations); + readonly educationItems = select(UserSelectors.getEducation); constructor() { - effect(() => { - const educations = this.educationItems(); - if (educations && educations.length > 0) { - this.educations.clear(); - educations.forEach((education) => { - const newEducation = this.fb.group({ - institution: [education.institution], - department: [education.department], - degree: [education.degree], - startDate: [new Date(+education.startYear, education.startMonth - 1)], - endDate: education.ongoing - ? '' - : education.endYear && education.endMonth - ? [new Date(+education.endYear, education.endMonth - 1)] - : null, - ongoing: [education.ongoing], - }); - this.educations.push(newEducation); - }); - } - }); + effect(() => this.setInitialData()); } - get educations() { - return this.educationForm.get('educations') as FormArray; + get educations(): FormArray { + return this.educationForm.get('educations') as FormArray; } removeEducation(index: number): void { @@ -64,50 +58,117 @@ export class EducationComponent { } addEducation(): void { - const newEducation = this.fb.group({ - institution: [''], - department: [''], - degree: [''], - startDate: [null], - endDate: [null], - ongoing: [false], + if (this.educationForm.invalid) { + this.educationForm.markAllAsTouched(); + return; + } + + this.educations.push(this.createEducationFormGroup()); + } + + discardChanges(): void { + if (!this.hasFormChanges()) { + return; + } + + this.customConfirmationService.confirmDelete({ + headerKey: 'common.discardChangesDialog.header', + messageKey: 'common.discardChangesDialog.message', + onConfirm: () => { + this.setInitialData(); + this.cd.markForCheck(); + }, }); - this.educations.push(newEducation); } saveEducation(): void { - const educations = this.educations.value as EducationForm[]; + if (this.educationForm.invalid) { + this.educationForm.markAllAsTouched(); + return; + } + + const formattedEducation = this.educations.value.map((education) => this.mapFormToEducation(education)); + this.loaderService.show(); + + this.actions + .updateProfileSettingsEducation({ education: formattedEducation }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.loaderService.hide(); + this.toastService.showSuccess('settings.profileSettings.education.successUpdate'); + }, + error: () => this.loaderService.hide(), + }); + } + + private hasFormChanges(): boolean { + if (this.educations.length !== this.educationItems().length) { + return true; + } - const formattedEducation = educations.map((education) => ({ + return this.educations.value.some((formEducation, index) => { + const initialEdu = this.educationItems()[index]; + if (!initialEdu) return true; + + const formattedFormEducation = this.mapFormToEducation(formEducation); + const changedFields = findChangedFields(formattedFormEducation, initialEdu); + + return Object.keys(changedFields).length > 0; + }); + } + + private createEducationFormGroup(education?: Partial): FormGroup { + return this.fb.group( + { + institution: [education?.institution ?? '', CustomValidators.requiredTrimmed()], + department: [education?.department ?? ''], + degree: [education?.degree ?? ''], + startDate: [education?.startDate ?? null], + endDate: [education?.endDate ?? null], + ongoing: [education?.ongoing ?? false], + }, + { validators: CustomValidators.dateRangeValidator } + ); + } + + private setInitialData(): void { + const educations = this.educationItems(); + if (!educations?.length) return; + + this.educations.clear(); + educations + .map((education) => this.mapEducationToForm(education)) + .forEach((education) => this.educations.push(this.createEducationFormGroup(education))); + + this.cd.markForCheck(); + } + + private mapFormToEducation(education: EducationForm): Education { + return { institution: education.institution, department: education.department, degree: education.degree, - startYear: this.setupDates(education.startDate, null).startYear, - startMonth: this.setupDates(education.startDate, null).startMonth, - endYear: education.ongoing ? null : this.setupDates('', education.endDate).endYear, - endMonth: education.ongoing ? null : this.setupDates('', education.endDate).endMonth, + startYear: education.startDate?.getFullYear() ?? new Date().getFullYear(), + startMonth: (education.startDate?.getMonth() ?? 0) + 1, + endYear: education.ongoing ? null : (education.endDate?.getFullYear() ?? null), + endMonth: education.ongoing ? null : education.endDate ? education.endDate.getMonth() + 1 : null, ongoing: education.ongoing, - })) satisfies Education[]; - - this.actions.updateProfileSettingsEducation({ education: formattedEducation }); + }; } - private setupDates( - startDate: Date | string, - endDate: Date | null - ): { - startYear: number; - startMonth: number; - endYear: number | null; - endMonth: number | null; - } { - const start = new Date(startDate); - const end = endDate ? new Date(endDate) : null; + private mapEducationToForm(education: Education): EducationForm { return { - startYear: start.getFullYear(), - startMonth: start.getMonth() + 1, - endYear: end ? end.getFullYear() : null, - endMonth: end ? end.getMonth() + 1 : null, + institution: education.institution, + department: education.department, + degree: education.degree, + startDate: new Date(+education.startYear, education.startMonth - 1), + endDate: education.ongoing + ? null + : education.endYear && education.endMonth + ? new Date(+education.endYear, education.endMonth - 1) + : null, + ongoing: education.ongoing, }; } } diff --git a/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.html b/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.html new file mode 100644 index 000000000..69dc63b08 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.html @@ -0,0 +1,103 @@ +
+
+

+ {{ 'settings.profileSettings.employment.title' | translate: { index: index() + 1 } }} +

+ + @if (index() !== 0) { +
+ +
+ } +
+ +
+
+ +
+ +
+
+ + + +
+ +
+ + + +
+
+ +
+
+
+ + + +
+ + @if (!group().controls['ongoing'].value) { +
+ + + +
+ } +
+ +
+ + + +
+
+ + @if (isDateError) { + + {{ 'settings.profileSettings.endDateError' | translate }} + + } +
+
diff --git a/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.scss b/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.spec.ts b/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.spec.ts new file mode 100644 index 000000000..6621a37e8 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EmploymentFormComponent } from './employment-form.component'; + +describe('EmploymentFormComponent', () => { + let component: EmploymentFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EmploymentFormComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(EmploymentFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.ts b/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.ts new file mode 100644 index 000000000..1f8f74fac --- /dev/null +++ b/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.ts @@ -0,0 +1,55 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Checkbox } from 'primeng/checkbox'; +import { DatePicker } from 'primeng/datepicker'; +import { InputText } from 'primeng/inputtext'; +import { Message } from 'primeng/message'; + +import { filter } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, OnInit, output } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; + +import { MAX_DATE, MIN_DATE } from '@osf/features/settings/profile-settings/constants'; +import { TextInputComponent } from '@osf/shared/components'; +import { InputLimits } from '@osf/shared/constants'; + +@Component({ + selector: 'osf-employment-form', + imports: [ReactiveFormsModule, Button, InputText, DatePicker, Checkbox, Message, TranslatePipe, TextInputComponent], + templateUrl: './employment-form.component.html', + styleUrl: './employment-form.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EmploymentFormComponent implements OnInit { + readonly maxDate = MAX_DATE; + readonly minDate = MIN_DATE; + readonly institutionMaxLength = InputLimits.fullName.maxLength; + readonly dateFormat = 'mm/dd/yy'; + + private readonly destroyRef = inject(DestroyRef); + + group = input.required(); + index = input.required(); + remove = output(); + + get titleControl() { + return this.group().controls['title'] as FormControl; + } + + get isDateError() { + const form = this.group(); + return form.errors && form.errors['dateRangeInvalid']; + } + + ngOnInit() { + this.group() + .controls['ongoing'].valueChanges.pipe( + filter((res) => !!res), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.group().controls['endDate'].setValue(null)); + } +} diff --git a/src/app/features/settings/profile-settings/components/employment/employment.component.html b/src/app/features/settings/profile-settings/components/employment/employment.component.html index 6a89525f8..13676a884 100644 --- a/src/app/features/settings/profile-settings/components/employment/employment.component.html +++ b/src/app/features/settings/profile-settings/components/employment/employment.component.html @@ -3,91 +3,8 @@
@if (havePositions) { - @for (position of positions.controls; track position.value; let index = $index) { -
-
-

- {{ 'settings.profileSettings.employment.title' | translate: { index: index + 1 } }} -

- @if (index !== 0) { -
- - -
- } -
- -
-
- - -
- -
-
- - -
-
- - -
-
- -
-
-
- - -
-
- - -
-
- -
- - -
-
-
-
+ @for (position of positions.controls; track index; let index = $index) { + } }
@@ -103,11 +20,13 @@

+ -
- -
diff --git a/src/app/features/settings/profile-settings/components/employment/employment.component.ts b/src/app/features/settings/profile-settings/components/employment/employment.component.ts index 4a66c6db3..5861e77cb 100644 --- a/src/app/features/settings/profile-settings/components/employment/employment.component.ts +++ b/src/app/features/settings/profile-settings/components/employment/employment.component.ts @@ -3,22 +3,30 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; -import { Checkbox } from 'primeng/checkbox'; -import { DatePicker } from 'primeng/datepicker'; -import { InputText } from 'primeng/inputtext'; - -import { ChangeDetectionStrategy, Component, effect, HostBinding, inject } from '@angular/core'; -import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + effect, + HostBinding, + inject, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; + +import { UpdateProfileSettingsEmployment, UserSelectors } from '@osf/core/store/user'; import { Employment } from '@osf/shared/models'; -import { CustomValidators } from '@osf/shared/utils'; +import { CustomConfirmationService, LoaderService, ToastService } from '@osf/shared/services'; +import { CustomValidators, findChangedFields } from '@osf/shared/utils'; import { EmploymentForm } from '../../models'; -import { ProfileSettingsSelectors, UpdateProfileSettingsEmployment } from '../../store'; +import { EmploymentFormComponent } from '../employment-form/employment-form.component'; @Component({ selector: 'osf-employment', - imports: [Button, Checkbox, DatePicker, InputText, ReactiveFormsModule, TranslatePipe], + imports: [Button, ReactiveFormsModule, TranslatePipe, EmploymentFormComponent], templateUrl: './employment.component.html', styleUrl: './employment.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -26,94 +34,140 @@ import { ProfileSettingsSelectors, UpdateProfileSettingsEmployment } from '../.. export class EmploymentComponent { @HostBinding('class') classes = 'flex flex-column gap-5'; + private readonly loaderService = inject(LoaderService); + private readonly customConfirmationService = inject(CustomConfirmationService); + private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + private readonly cd = inject(ChangeDetectorRef); + private readonly fb = inject(FormBuilder); + readonly actions = createDispatchMap({ updateProfileSettingsEmployment: UpdateProfileSettingsEmployment }); - readonly employment = select(ProfileSettingsSelectors.employment); + readonly employment = select(UserSelectors.getEmployment); - readonly fb = inject(FormBuilder); readonly employmentForm = this.fb.group({ positions: this.fb.array([]) }); constructor() { - effect(() => { - const employment = this.employment(); - - if (employment && employment.length > 0) { - this.positions.clear(); - - employment.forEach((position) => { - const positionGroup = this.fb.group({ - title: [position.title, Validators.required], - department: [position.department], - institution: [position.institution, Validators.required], - startDate: [new Date(+position.startYear, position.startMonth - 1)], - endDate: position.ongoing - ? '' - : position.endYear && position.endMonth - ? [new Date(+position.endYear, position.endMonth - 1)] - : null, - ongoing: !position.ongoing, - }); - - this.positions.push(positionGroup); - }); - } - }); + effect(() => this.setInitialData()); } - get positions(): FormArray { - return this.employmentForm.get('positions') as FormArray; + get positions(): FormArray { + return this.employmentForm.get('positions') as FormArray; + } + + removePosition(index: number): void { + this.positions.removeAt(index); } addPosition(): void { - const positionGroup = this.fb.group({ - title: ['', CustomValidators.requiredTrimmed()], - department: [''], - institution: ['', Validators.required], - startDate: [null, Validators.required], - endDate: [null, Validators.required], - ongoing: [false], + if (this.employmentForm.invalid) { + this.employmentForm.markAllAsTouched(); + return; + } + + this.positions.push(this.createEmploymentFormGroup()); + } + + discardChanges(): void { + if (!this.hasFormChanges()) { + return; + } + + this.customConfirmationService.confirmDelete({ + headerKey: 'common.discardChangesDialog.header', + messageKey: 'common.discardChangesDialog.message', + onConfirm: () => { + this.setInitialData(); + this.cd.markForCheck(); + }, }); + } - this.positions.push(positionGroup); + saveEmployment(): void { + if (this.employmentForm.invalid) { + this.employmentForm.markAllAsTouched(); + return; + } + + const formattedEmployment = this.positions.value.map((position) => this.mapFormToEmployment(position)); + this.loaderService.show(); + + this.actions + .updateProfileSettingsEmployment({ employment: formattedEmployment }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.loaderService.hide(); + this.toastService.showSuccess('settings.profileSettings.employment.successUpdate'); + }, + error: () => this.loaderService.hide(), + }); } - removePosition(index: number): void { - this.positions.removeAt(index); + private hasFormChanges(): boolean { + if (this.positions.length !== this.employment().length) { + return true; + } + + return this.positions.value.some((formEmployment, index) => { + const initial = this.employment()[index]; + if (!initial) return true; + + const formattedFormEducation = this.mapFormToEmployment(formEmployment); + const changedFields = findChangedFields(formattedFormEducation, initial); + + return Object.keys(changedFields).length > 0; + }); } - handleSavePositions(): void { - const employments = this.positions.value as EmploymentForm[]; + private createEmploymentFormGroup(employment?: Partial): FormGroup { + return this.fb.group( + { + institution: [employment?.institution ?? '', CustomValidators.requiredTrimmed()], + title: [employment?.title ?? ''], + department: [employment?.department ?? ''], + startDate: [employment?.startDate ?? null], + endDate: [employment?.endDate ?? null], + ongoing: [employment?.ongoing ?? false], + }, + { validators: CustomValidators.dateRangeValidator } + ); + } + + private setInitialData(): void { + const employment = this.employment(); + if (!employment?.length) return; + + this.positions.clear(); + employment + .map((x) => this.mapEmploymentToForm(x)) + .forEach((x) => this.positions.push(this.createEmploymentFormGroup(x))); + } - const formattedEmployments = employments.map((employment) => ({ + private mapFormToEmployment(employment: EmploymentForm): Employment { + return { title: employment.title, department: employment.department, institution: employment.institution, - startYear: this.setupDates(employment.startDate, null).startYear, - startMonth: this.setupDates(employment.startDate, null).startMonth, - endYear: employment.ongoing ? null : this.setupDates('', employment.endDate).endYear, - endMonth: employment.ongoing ? null : this.setupDates('', employment.endDate).endMonth, - ongoing: !employment.ongoing, - })) satisfies Employment[]; - - this.actions.updateProfileSettingsEmployment({ employment: formattedEmployments }); + startYear: employment.startDate?.getFullYear() ?? new Date().getFullYear(), + startMonth: (employment.startDate?.getMonth() ?? 0) + 1, + endYear: employment.ongoing ? null : (employment.endDate?.getFullYear() ?? null), + endMonth: employment.ongoing ? null : employment.endDate ? employment.endDate.getMonth() + 1 : null, + ongoing: employment.ongoing, + }; } - private setupDates( - startDate: Date | string, - endDate: Date | string | null - ): { - startYear: string | number; - startMonth: number; - endYear: number | null; - endMonth: number | null; - } { - const start = new Date(startDate); - const end = endDate ? new Date(endDate) : null; - + private mapEmploymentToForm(employment: Employment): EmploymentForm { return { - startYear: start.getFullYear(), - startMonth: start.getMonth() + 1, - endYear: end ? end.getFullYear() : null, - endMonth: end ? end.getMonth() + 1 : null, + title: employment.title, + department: employment.department, + institution: employment.institution, + startDate: new Date(+employment.startYear, employment.startMonth - 1), + endDate: employment.ongoing + ? null + : employment.endYear && employment.endMonth + ? new Date(+employment.endYear, employment.endMonth - 1) + : null, + ongoing: employment.ongoing, }; } } diff --git a/src/app/features/settings/profile-settings/components/index.ts b/src/app/features/settings/profile-settings/components/index.ts index e4339768a..446ea66b2 100644 --- a/src/app/features/settings/profile-settings/components/index.ts +++ b/src/app/features/settings/profile-settings/components/index.ts @@ -1,4 +1,8 @@ +export { CitationPreviewComponent } from './citation-preview/citation-preview.component'; export { EducationComponent } from './education/education.component'; +export { EducationFormComponent } from './education-form/education-form.component'; export { EmploymentComponent } from './employment/employment.component'; +export { EmploymentFormComponent } from './employment-form/employment-form.component'; export { NameComponent } from './name/name.component'; +export { NameFormComponent } from './name-form/name-form.component'; export { SocialComponent } from './social/social.component'; diff --git a/src/app/features/settings/profile-settings/components/name-form/name-form.component.html b/src/app/features/settings/profile-settings/components/name-form/name-form.component.html new file mode 100644 index 000000000..2744d39e6 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/name-form/name-form.component.html @@ -0,0 +1,53 @@ +
+

+ {{ 'settings.profileSettings.name.description' | translate }} +

+ + +
+
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
diff --git a/src/app/features/settings/profile-settings/components/name-form/name-form.component.scss b/src/app/features/settings/profile-settings/components/name-form/name-form.component.scss new file mode 100644 index 000000000..33318f598 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/name-form/name-form.component.scss @@ -0,0 +1,3 @@ +.name-container { + border: 1px solid var(--grey-2); +} diff --git a/src/app/features/settings/profile-settings/components/name-form/name-form.component.spec.ts b/src/app/features/settings/profile-settings/components/name-form/name-form.component.spec.ts new file mode 100644 index 000000000..1ae418419 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/name-form/name-form.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NameFormComponent } from './name-form.component'; + +describe('NameFormComponent', () => { + let component: NameFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NameFormComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(NameFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/settings/profile-settings/components/name-form/name-form.component.ts b/src/app/features/settings/profile-settings/components/name-form/name-form.component.ts new file mode 100644 index 000000000..0b916296d --- /dev/null +++ b/src/app/features/settings/profile-settings/components/name-form/name-form.component.ts @@ -0,0 +1,22 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; + +import { TextInputComponent } from '@osf/shared/components'; +import { InputLimits } from '@osf/shared/constants'; + +import { NameForm } from '../../models'; + +@Component({ + selector: 'osf-name-form', + imports: [ReactiveFormsModule, TranslatePipe, TextInputComponent], + templateUrl: './name-form.component.html', + styleUrl: './name-form.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NameFormComponent { + form = input.required>(); + + readonly inputLimits = InputLimits; +} diff --git a/src/app/features/settings/profile-settings/components/name/name.component.html b/src/app/features/settings/profile-settings/components/name/name.component.html index b539822cb..edbfd80b9 100644 --- a/src/app/features/settings/profile-settings/components/name/name.component.html +++ b/src/app/features/settings/profile-settings/components/name/name.component.html @@ -1,105 +1,19 @@ -
-

- {{ 'settings.profileSettings.name.description' | translate }} -

+ -
-
-
- + - -
- -
- -
-
- -
-
- - - -
- -
- - - -
-
- -
-
- - -
-
- - -
-
-
-
-
-
-

- {{ 'settings.profileSettings.name.citationPreview.title' | translate }} -

- -
-
-
-

- {{ 'settings.profileSettings.name.citationPreview.style' | translate }} -

-

- {{ 'settings.profileSettings.name.citationPreview.citationFormat' | translate }} -

-
-
-

APA

-

Doe, J. T.

-
-
- -
-
-

- {{ 'settings.profileSettings.name.citationPreview.style' | translate }} -

-

- {{ 'settings.profileSettings.name.citationPreview.citationFormat' | translate }} -

-
-
-

MLA

-

Doe, John T.

-
-
-
-
-
- -
+
+ + -
- -
diff --git a/src/app/features/settings/profile-settings/components/name/name.component.scss b/src/app/features/settings/profile-settings/components/name/name.component.scss index 4c3bab073..e69de29bb 100644 --- a/src/app/features/settings/profile-settings/components/name/name.component.scss +++ b/src/app/features/settings/profile-settings/components/name/name.component.scss @@ -1,25 +0,0 @@ -@use "assets/styles/mixins" as mix; - -.name-container { - border: 1px solid var(--grey-2); - border-radius: 0.5rem; - - label { - font-weight: 300; - color: var(--dark-blue-1); - } - - .name-input { - width: 100%; - border: 1px solid (--grey-2); - border-radius: 0.5rem; - } - - .styles-container { - column-gap: 8.5rem; - - .style-wrapper { - row-gap: 0.85rem; - } - } -} diff --git a/src/app/features/settings/profile-settings/components/name/name.component.ts b/src/app/features/settings/profile-settings/components/name/name.component.ts index eb0c734c0..e238a70ef 100644 --- a/src/app/features/settings/profile-settings/components/name/name.component.ts +++ b/src/app/features/settings/profile-settings/components/name/name.component.ts @@ -1,19 +1,25 @@ -import { select, Store } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; -import { InputText } from 'primeng/inputtext'; -import { ChangeDetectionStrategy, Component, effect, HostBinding, inject } from '@angular/core'; -import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, DestroyRef, effect, HostBinding, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormBuilder } from '@angular/forms'; + +import { User } from '@osf/core/models'; +import { UpdateProfileSettingsUser, UserSelectors } from '@osf/core/store/user'; +import { LoaderService, ToastService } from '@osf/shared/services'; +import { CustomValidators } from '@osf/shared/utils'; import { NameForm } from '../../models'; -import { ProfileSettingsSelectors, UpdateProfileSettingsUser } from '../../store'; +import { CitationPreviewComponent } from '../citation-preview/citation-preview.component'; +import { NameFormComponent } from '../name-form/name-form.component'; @Component({ selector: 'osf-name', - imports: [Button, InputText, ReactiveFormsModule, TranslatePipe], + imports: [Button, TranslatePipe, CitationPreviewComponent, NameFormComponent], templateUrl: './name.component.html', styleUrl: './name.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -21,34 +27,49 @@ import { ProfileSettingsSelectors, UpdateProfileSettingsUser } from '../../store export class NameComponent { @HostBinding('class') classes = 'flex flex-column gap-4 flex-1'; + private readonly loaderService = inject(LoaderService); + private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + + readonly actions = createDispatchMap({ updateProfileSettingsUser: UpdateProfileSettingsUser }); + readonly currentUser = select(UserSelectors.getUserNames); + readonly previewUser = signal>({}); + readonly fb = inject(FormBuilder); readonly form = this.fb.group({ - fullName: this.fb.control('', { nonNullable: true }), + fullName: this.fb.control('', { nonNullable: true, validators: [CustomValidators.requiredTrimmed()] }), givenName: this.fb.control('', { nonNullable: true }), middleNames: this.fb.control('', { nonNullable: true }), familyName: this.fb.control('', { nonNullable: true }), suffix: this.fb.control('', { nonNullable: true }), }); - readonly store = inject(Store); - readonly nameState = select(ProfileSettingsSelectors.user); constructor() { effect(() => { - const user = this.nameState(); - this.form.patchValue({ - fullName: user.fullName, - givenName: user.givenName, - middleNames: user.middleNames, - familyName: user.familyName, - suffix: user.suffix, - }); + const user = this.currentUser(); + + if (!user) { + return; + } + + this.updateForm(user); + }); + + this.form.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + this.updatePreviewUser(); }); } saveChanges() { + if (this.form.invalid) { + return; + } + const { fullName, givenName, middleNames, familyName, suffix } = this.form.getRawValue(); - this.store.dispatch( - new UpdateProfileSettingsUser({ + + this.loaderService.show(); + this.actions + .updateProfileSettingsUser({ user: { fullName, givenName, @@ -57,6 +78,39 @@ export class NameComponent { suffix, }, }) - ); + .subscribe(() => { + this.loaderService.hide(); + this.toastService.showSuccess('settings.profileSettings.name.successUpdate'); + }); + } + + discardChanges() { + const user = this.currentUser(); + + if (!user) { + return; + } + + this.updateForm(user); + } + + private updateForm(user: Partial) { + this.form.patchValue({ + fullName: user.fullName, + givenName: user.givenName, + middleNames: user.middleNames, + familyName: user.familyName, + suffix: user.suffix, + }); + } + + private updatePreviewUser() { + const formValues = this.form.getRawValue(); + const currentUserValue = this.currentUser(); + + this.previewUser.set({ + ...currentUserValue, + ...formValues, + }); } } diff --git a/src/app/features/settings/profile-settings/components/social-form/social-form.component.html b/src/app/features/settings/profile-settings/components/social-form/social-form.component.html new file mode 100644 index 000000000..ca6686fba --- /dev/null +++ b/src/app/features/settings/profile-settings/components/social-form/social-form.component.html @@ -0,0 +1,44 @@ +
+
+

+ {{ 'settings.profileSettings.social.title' | translate: { index: index() + 1 } }} +

+ @if (index() !== 0) { +
+ +
+ } +
+ +
+
+

+ {{ 'settings.profileSettings.social.socialOutput' | translate }} +

+ + +
+ +
+

+ {{ 'settings.profileSettings.social.webAddress' | translate }} +

+ + + + {{ domain }} + + + + +
+
+
diff --git a/src/app/features/settings/profile-settings/components/social-form/social-form.component.scss b/src/app/features/settings/profile-settings/components/social-form/social-form.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/settings/profile-settings/components/social-form/social-form.component.spec.ts b/src/app/features/settings/profile-settings/components/social-form/social-form.component.spec.ts new file mode 100644 index 000000000..3cccbd8fe --- /dev/null +++ b/src/app/features/settings/profile-settings/components/social-form/social-form.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SocialFormComponent } from './social-form.component'; + +describe('SocialFormComponent', () => { + let component: SocialFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SocialFormComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SocialFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/settings/profile-settings/components/social-form/social-form.component.ts b/src/app/features/settings/profile-settings/components/social-form/social-form.component.ts new file mode 100644 index 000000000..f64c538f0 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/social-form/social-form.component.ts @@ -0,0 +1,38 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { InputGroup } from 'primeng/inputgroup'; +import { InputGroupAddon } from 'primeng/inputgroupaddon'; +import { InputText } from 'primeng/inputtext'; +import { SelectModule } from 'primeng/select'; + +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; + +import { InputLimits } from '@osf/shared/constants'; + +import { socials } from '../../constants/data'; + +@Component({ + selector: 'osf-social-form', + imports: [Button, SelectModule, InputGroup, InputGroupAddon, InputText, ReactiveFormsModule, TranslatePipe], + templateUrl: './social-form.component.html', + styleUrl: './social-form.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SocialFormComponent { + readonly socials = socials; + readonly socialMaxLength = InputLimits.name.maxLength; + + group = input.required(); + index = input.required(); + remove = output(); + + get domain(): string { + return this.group().get('socialOutput')?.value?.address; + } + + get placeholder(): string { + return this.group().get('socialOutput')?.value?.placeholder; + } +} diff --git a/src/app/features/settings/profile-settings/components/social/social.component.html b/src/app/features/settings/profile-settings/components/social/social.component.html index 5c89b76a3..1bdb6d5e1 100644 --- a/src/app/features/settings/profile-settings/components/social/social.component.html +++ b/src/app/features/settings/profile-settings/components/social/social.component.html @@ -2,53 +2,7 @@ @@ -64,11 +18,13 @@

-
- -
+
diff --git a/src/app/features/settings/profile-settings/components/social/social.component.scss b/src/app/features/settings/profile-settings/components/social/social.component.scss index 956f2eea5..e69de29bb 100644 --- a/src/app/features/settings/profile-settings/components/social/social.component.scss +++ b/src/app/features/settings/profile-settings/components/social/social.component.scss @@ -1,3 +0,0 @@ -.surface-border { - --p-surface-200: var(--grey-2); -} diff --git a/src/app/features/settings/profile-settings/components/social/social.component.ts b/src/app/features/settings/profile-settings/components/social/social.component.ts index 0adb86ebc..3abc3245b 100644 --- a/src/app/features/settings/profile-settings/components/social/social.component.ts +++ b/src/app/features/settings/profile-settings/components/social/social.component.ts @@ -3,23 +3,21 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; -import { InputGroup } from 'primeng/inputgroup'; -import { InputGroupAddon } from 'primeng/inputgroupaddon'; -import { InputText } from 'primeng/inputtext'; -import { SelectModule } from 'primeng/select'; import { ChangeDetectionStrategy, Component, effect, HostBinding, inject } from '@angular/core'; -import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { UpdateProfileSettingsSocialLinks, UserSelectors } from '@osf/core/store/user'; import { Social } from '@osf/shared/models'; +import { LoaderService, ToastService } from '@osf/shared/services'; import { socials } from '../../constants/data'; import { SOCIAL_KEYS, SocialLinksForm, SocialLinksKeys, UserSocialLink } from '../../models'; -import { ProfileSettingsSelectors, UpdateProfileSettingsSocialLinks } from '../../store'; +import { SocialFormComponent } from '../social-form/social-form.component'; @Component({ selector: 'osf-social', - imports: [Button, SelectModule, InputGroup, InputGroupAddon, InputText, ReactiveFormsModule, TranslatePipe], + imports: [Button, ReactiveFormsModule, SocialFormComponent, TranslatePipe], templateUrl: './social.component.html', styleUrl: './social.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -30,8 +28,11 @@ export class SocialComponent { protected readonly socials = socials; readonly userSocialLinks: UserSocialLink[] = []; + private readonly loaderService = inject(LoaderService); + private readonly toastService = inject(ToastService); + readonly actions = createDispatchMap({ updateProfileSettingsSocialLinks: UpdateProfileSettingsSocialLinks }); - readonly socialLinks = select(ProfileSettingsSelectors.socialLinks); + readonly socialLinks = select(UserSelectors.getSocialLinks); readonly fb = inject(FormBuilder); readonly socialLinksForm = this.fb.group({ links: this.fb.array([]) }); @@ -40,6 +41,8 @@ export class SocialComponent { effect(() => { const socialLinks = this.socialLinks(); + this.links.clear(); + for (const socialLinksKey in socialLinks) { const socialLink = socialLinks[socialLinksKey as SocialLinksKeys]; @@ -53,8 +56,8 @@ export class SocialComponent { }); } - get links(): FormArray { - return this.socialLinksForm.get('links') as FormArray; + get links(): FormArray { + return this.socialLinksForm.get('links') as FormArray; } addLink(): void { @@ -70,14 +73,6 @@ export class SocialComponent { this.links.removeAt(index); } - getDomain(index: number): string { - return this.links.at(index).get('socialOutput')?.value?.address; - } - - getPlaceholder(index: number): string { - return this.links.at(index).get('socialOutput')?.value?.placeholder; - } - saveSocialLinks(): void { const links = this.socialLinksForm.value.links as SocialLinksForm[]; @@ -91,6 +86,11 @@ export class SocialComponent { }; }) satisfies Partial[]; - this.actions.updateProfileSettingsSocialLinks({ socialLinks: mappedLinks }); + this.loaderService.show(); + + this.actions.updateProfileSettingsSocialLinks({ socialLinks: mappedLinks }).subscribe(() => { + this.loaderService.hide(); + this.toastService.showSuccess('settings.profileSettings.social.successUpdate'); + }); } } diff --git a/src/app/features/settings/profile-settings/constants/data.ts b/src/app/features/settings/profile-settings/constants/data.ts index 1834b37a9..e8308c445 100644 --- a/src/app/features/settings/profile-settings/constants/data.ts +++ b/src/app/features/settings/profile-settings/constants/data.ts @@ -10,18 +10,18 @@ export const socials: SocialLinksModel[] = [ }, { id: 1, - label: 'LinkedIn', - address: 'https://linkedin.com/', - placeholder: 'in/userID, profie/view?profileID, or pub/pubID', - key: 'linkedIn', - }, - { - id: 2, label: 'ORCID', address: 'http://orcid.org/', placeholder: 'xxxx-xxxx-xxxx', key: 'orcid', }, + { + id: 2, + label: 'LinkedIn', + address: 'https://linkedin.com/', + placeholder: 'in/userID, profie/view?profileID, or pub/pubID', + key: 'linkedIn', + }, { id: 3, label: 'X', diff --git a/src/app/features/settings/profile-settings/constants/index.ts b/src/app/features/settings/profile-settings/constants/index.ts index db5b8f111..aac13faf0 100644 --- a/src/app/features/settings/profile-settings/constants/index.ts +++ b/src/app/features/settings/profile-settings/constants/index.ts @@ -1 +1,2 @@ +export * from './limits.const'; export * from './profile-settings-tab-options.const'; diff --git a/src/app/features/settings/profile-settings/constants/limits.const.ts b/src/app/features/settings/profile-settings/constants/limits.const.ts new file mode 100644 index 000000000..81e7b0bbe --- /dev/null +++ b/src/app/features/settings/profile-settings/constants/limits.const.ts @@ -0,0 +1,2 @@ +export const MAX_DATE = new Date(); +export const MIN_DATE = new Date(1900, 0, 1); diff --git a/src/app/features/settings/profile-settings/models/educations.model.ts b/src/app/features/settings/profile-settings/models/education-form.model.ts similarity index 100% rename from src/app/features/settings/profile-settings/models/educations.model.ts rename to src/app/features/settings/profile-settings/models/education-form.model.ts diff --git a/src/app/features/settings/profile-settings/models/employment.model.ts b/src/app/features/settings/profile-settings/models/employment-form.model.ts similarity index 65% rename from src/app/features/settings/profile-settings/models/employment.model.ts rename to src/app/features/settings/profile-settings/models/employment-form.model.ts index 3b336e38f..f47945d6f 100644 --- a/src/app/features/settings/profile-settings/models/employment.model.ts +++ b/src/app/features/settings/profile-settings/models/employment-form.model.ts @@ -3,6 +3,6 @@ export interface EmploymentForm { ongoing: boolean; department: string; institution: string; - startDate: Date | string; - endDate: Date | string | null; + startDate: Date; + endDate: Date | null; } diff --git a/src/app/features/settings/profile-settings/models/index.ts b/src/app/features/settings/profile-settings/models/index.ts index ca68911b7..3118024c6 100644 --- a/src/app/features/settings/profile-settings/models/index.ts +++ b/src/app/features/settings/profile-settings/models/index.ts @@ -1,6 +1,5 @@ -export * from './educations.model'; -export * from './employment.model'; -export * from './name.model'; +export * from './education-form.model'; +export * from './employment-form.model'; +export * from './name-form.model'; export * from './social.model'; -export * from './user-position.model'; export * from './user-social-link.model'; diff --git a/src/app/features/settings/profile-settings/models/name-form.model.ts b/src/app/features/settings/profile-settings/models/name-form.model.ts new file mode 100644 index 000000000..332b2ca6d --- /dev/null +++ b/src/app/features/settings/profile-settings/models/name-form.model.ts @@ -0,0 +1,9 @@ +import { FormControl } from '@angular/forms'; + +export interface NameForm { + fullName: FormControl; + givenName: FormControl; + middleNames: FormControl; + familyName: FormControl; + suffix: FormControl; +} diff --git a/src/app/features/settings/profile-settings/models/name.model.ts b/src/app/features/settings/profile-settings/models/name.model.ts deleted file mode 100644 index 08a3a8776..000000000 --- a/src/app/features/settings/profile-settings/models/name.model.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { FormControl } from '@angular/forms'; - -import { User } from '@osf/core/models'; - -export interface Name { - fullName: string; - givenName: string; - middleNames: string; - familyName: string; - email?: string; - suffix: string; -} - -export interface NameForm { - fullName: FormControl; - givenName: FormControl; - middleNames: FormControl; - familyName: FormControl; - suffix: FormControl; -} - -export interface NameDto { - full_name: string; - given_name: string; - family_name: string; - middle_names: string; - suffix: string; - email?: string; -} - -export function mapNameToDto(name: Name | Partial): NameDto { - return { - full_name: name.fullName ?? '', - given_name: name.givenName ?? '', - family_name: name.familyName ?? '', - middle_names: name.middleNames ?? '', - suffix: name.suffix ?? '', - email: name.email ?? '', - }; -} diff --git a/src/app/features/settings/profile-settings/models/user-position.model.ts b/src/app/features/settings/profile-settings/models/user-position.model.ts deleted file mode 100644 index d7d449a8b..000000000 --- a/src/app/features/settings/profile-settings/models/user-position.model.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface UserPosition { - jobTitle: string; - department: string; - institution: string; - startDate: Date | null; - endDate: Date | null; - presentlyEmployed: boolean; -} diff --git a/src/app/features/settings/profile-settings/services/index.ts b/src/app/features/settings/profile-settings/services/index.ts deleted file mode 100644 index 74bc66908..000000000 --- a/src/app/features/settings/profile-settings/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProfileSettingsApiService } from './profile-settings.api.service'; diff --git a/src/app/features/settings/profile-settings/services/profile-settings.api.service.ts b/src/app/features/settings/profile-settings/services/profile-settings.api.service.ts deleted file mode 100644 index 4f13a9df1..000000000 --- a/src/app/features/settings/profile-settings/services/profile-settings.api.service.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { inject, Injectable } from '@angular/core'; - -import { JsonApiResponse, UserGetResponse } from '@osf/core/models'; -import { JsonApiService } from '@osf/core/services'; - -import { ProfileSettingsStateModel, ProfileSettingsUpdate } from '../store'; - -import { environment } from 'src/environments/environment'; - -@Injectable({ - providedIn: 'root', -}) -export class ProfileSettingsApiService { - private readonly jsonApiService = inject(JsonApiService); - - patchUserSettings(userId: string, key: keyof ProfileSettingsStateModel, data: ProfileSettingsUpdate) { - const patchedData = { [key]: data }; - - return this.jsonApiService.patch>(`${environment.apiUrl}/users/${userId}/`, { - data: { type: 'users', id: userId, attributes: patchedData }, - }); - } -} diff --git a/src/app/features/settings/profile-settings/store/index.ts b/src/app/features/settings/profile-settings/store/index.ts deleted file mode 100644 index 2921f71cd..000000000 --- a/src/app/features/settings/profile-settings/store/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './profile-settings.actions'; -export * from './profile-settings.model'; -export * from './profile-settings.selectors'; -export * from './profile-settings.state'; diff --git a/src/app/features/settings/profile-settings/store/profile-settings.actions.ts b/src/app/features/settings/profile-settings/store/profile-settings.actions.ts deleted file mode 100644 index 514ffd30d..000000000 --- a/src/app/features/settings/profile-settings/store/profile-settings.actions.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { User } from '@osf/core/models'; -import { Education, Employment, Social } from '@osf/shared/models'; - -export class SetupProfileSettings { - static readonly type = '[Profile Settings] Setup Profile Settings'; -} - -export class UpdateProfileSettingsEmployment { - static readonly type = '[Profile Settings] Update Employment'; - - constructor(public payload: { employment: Employment[] }) {} -} - -export class UpdateProfileSettingsEducation { - static readonly type = '[Profile Settings] Update Education'; - - constructor(public payload: { education: Education[] }) {} -} - -export class UpdateProfileSettingsSocialLinks { - static readonly type = '[Profile Settings] Update Social Links'; - - constructor(public payload: { socialLinks: Partial[] }) {} -} - -export class UpdateProfileSettingsUser { - static readonly type = '[Profile Settings] Update User'; - - constructor(public payload: { user: Partial }) {} -} diff --git a/src/app/features/settings/profile-settings/store/profile-settings.model.ts b/src/app/features/settings/profile-settings/store/profile-settings.model.ts deleted file mode 100644 index 53b1b1121..000000000 --- a/src/app/features/settings/profile-settings/store/profile-settings.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { User } from '@osf/core/models'; -import { Education, Employment, Social } from '@osf/shared/models'; - -export const PROFILE_SETTINGS_STATE_NAME = 'profileSettings'; - -export interface ProfileSettingsStateModel { - employment: Employment[]; - education: Education[]; - social: Social; - user: Partial; -} - -export type ProfileSettingsUpdate = Partial[] | Partial[] | Partial | Partial; - -export const PROFILE_SETTINGS_INITIAL_STATE: ProfileSettingsStateModel = { - employment: [], - education: [], - social: {} as Social, - user: { - id: '', - fullName: '', - email: '', - givenName: '', - familyName: '', - middleNames: '', - suffix: '', - }, -}; diff --git a/src/app/features/settings/profile-settings/store/profile-settings.selectors.ts b/src/app/features/settings/profile-settings/store/profile-settings.selectors.ts deleted file mode 100644 index 18c05e862..000000000 --- a/src/app/features/settings/profile-settings/store/profile-settings.selectors.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Selector } from '@ngxs/store'; - -import { User } from '@osf/core/models'; -import { Education, Employment, Social } from '@osf/shared/models'; - -import { ProfileSettingsStateModel } from './profile-settings.model'; -import { ProfileSettingsState } from './profile-settings.state'; - -export class ProfileSettingsSelectors { - @Selector([ProfileSettingsState]) - static educations(state: ProfileSettingsStateModel): Education[] { - return state.education; - } - - @Selector([ProfileSettingsState]) - static employment(state: ProfileSettingsStateModel): Employment[] { - return state.employment; - } - - @Selector([ProfileSettingsState]) - static socialLinks(state: ProfileSettingsStateModel): Social { - return state.social; - } - - @Selector([ProfileSettingsState]) - static user(state: ProfileSettingsStateModel): Partial { - return state.user; - } -} diff --git a/src/app/features/settings/profile-settings/store/profile-settings.state.ts b/src/app/features/settings/profile-settings/store/profile-settings.state.ts deleted file mode 100644 index e2c5e7f64..000000000 --- a/src/app/features/settings/profile-settings/store/profile-settings.state.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { Action, State, StateContext, Store } from '@ngxs/store'; - -import { tap } from 'rxjs'; - -import { inject, Injectable } from '@angular/core'; - -import { UserSelectors } from '@osf/core/store/user'; -import { removeNullable } from '@osf/shared/constants'; -import { Social } from '@osf/shared/models'; - -import { mapNameToDto } from '../models'; -import { ProfileSettingsApiService } from '../services'; - -import { - SetupProfileSettings, - UpdateProfileSettingsEducation, - UpdateProfileSettingsEmployment, - UpdateProfileSettingsSocialLinks, - UpdateProfileSettingsUser, -} from './profile-settings.actions'; -import { - PROFILE_SETTINGS_INITIAL_STATE, - PROFILE_SETTINGS_STATE_NAME, - ProfileSettingsStateModel, -} from './profile-settings.model'; - -@State({ - name: PROFILE_SETTINGS_STATE_NAME, - defaults: PROFILE_SETTINGS_INITIAL_STATE, -}) -@Injectable() -export class ProfileSettingsState { - private readonly store = inject(Store); - private readonly profileSettingsService = inject(ProfileSettingsApiService); - - @Action(SetupProfileSettings) - setupProfileSettings(ctx: StateContext): void { - const state = ctx.getState(); - const profileSettings = this.store.selectSnapshot(UserSelectors.getProfileSettings); - - ctx.patchState({ - ...state, - ...profileSettings, - }); - } - - @Action(UpdateProfileSettingsEmployment) - updateProfileSettingsEmployment( - ctx: StateContext, - { payload }: UpdateProfileSettingsEmployment - ) { - const state = ctx.getState(); - const userId = state.user.id; - - if (!userId) { - return; - } - - const withoutNulls = payload.employment.map((item) => removeNullable(item)); - - return this.profileSettingsService.patchUserSettings(userId, 'employment', withoutNulls).pipe( - tap((response) => { - ctx.patchState({ - ...state, - employment: response.data.attributes.employment, - }); - }) - ); - } - - @Action(UpdateProfileSettingsEducation) - updateProfileSettingsEducation( - ctx: StateContext, - { payload }: UpdateProfileSettingsEducation - ) { - const state = ctx.getState(); - const userId = state.user.id; - - if (!userId) { - return; - } - - const withoutNulls = payload.education.map((item) => removeNullable(item)); - - return this.profileSettingsService.patchUserSettings(userId, 'education', withoutNulls).pipe( - tap((response) => { - ctx.patchState({ - ...state, - education: response.data.attributes.education, - }); - }) - ); - } - - @Action(UpdateProfileSettingsUser) - updateProfileSettingsUser(ctx: StateContext, { payload }: UpdateProfileSettingsUser) { - const state = ctx.getState(); - const userId = state.user.id; - - if (!userId) { - return; - } - - const withoutNulls = mapNameToDto(removeNullable(payload.user)); - - return this.profileSettingsService.patchUserSettings(userId, 'user', withoutNulls).pipe( - tap((response) => { - ctx.patchState({ - ...state, - user: response.data.attributes, - }); - }) - ); - } - - @Action(UpdateProfileSettingsSocialLinks) - updateProfileSettingsSocialLinks( - ctx: StateContext, - { payload }: UpdateProfileSettingsSocialLinks - ) { - const state = ctx.getState(); - const userId = state.user.id; - - if (!userId) { - return; - } - - let social = {} as Partial; - - payload.socialLinks.forEach((item) => { - social = { - ...social, - ...item, - }; - }); - - return this.profileSettingsService.patchUserSettings(userId, 'social', social).pipe( - tap((response) => { - ctx.patchState({ - ...state, - social: response.data.attributes.social, - }); - }) - ); - } -} diff --git a/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.html b/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.html index bed186089..58e74fb85 100644 --- a/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.html +++ b/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.html @@ -20,7 +20,7 @@

-
+
@for (scope of tokenScopes(); track scope.id) {
diff --git a/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.scss b/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.scss index 97c455de0..e69de29bb 100644 --- a/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.scss +++ b/src/app/features/settings/tokens/components/token-add-edit-form/token-add-edit-form.component.scss @@ -1,4 +0,0 @@ -.scope-container { - height: 40vh; - overflow: auto; -} diff --git a/src/app/shared/components/files-tree/files-tree.component.ts b/src/app/shared/components/files-tree/files-tree.component.ts index db33128aa..42dde9da5 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -143,12 +143,6 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { this.updateFilesList().subscribe(() => this.folderIsOpening.emit(false)); } }); - - effect(() => { - const isLoading = this.isLoading(); - - console.log(isLoading); - }); } openEntry(file: OsfFile) { diff --git a/src/app/shared/constants/citation-suffix.const.ts b/src/app/shared/constants/citation-suffix.const.ts new file mode 100644 index 000000000..db8be03ab --- /dev/null +++ b/src/app/shared/constants/citation-suffix.const.ts @@ -0,0 +1,2 @@ +export const GENERATIONAL_SUFFIXES = ['jr', 'sr']; +export const ORDINAL_SUFFIXES = ['ii', 'iii', 'iv', 'v']; diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts index 64dbb2988..464083e26 100644 --- a/src/app/shared/enums/index.ts +++ b/src/app/shared/enums/index.ts @@ -10,6 +10,7 @@ export * from './file-menu-type.enum'; export * from './filter-type.enum'; export * from './get-resources-request-type.enum'; export * from './profile-addons-stepper.enum'; +export * from './profile-settings-key.enum'; export * from './registration-review-states.enum'; export * from './registry-status.enum'; export * from './resource-tab.enum'; diff --git a/src/app/shared/enums/profile-settings-key.enum.ts b/src/app/shared/enums/profile-settings-key.enum.ts new file mode 100644 index 000000000..c8407973e --- /dev/null +++ b/src/app/shared/enums/profile-settings-key.enum.ts @@ -0,0 +1,6 @@ +export enum ProfileSettingsKey { + User = 'user', + Social = 'social', + Employment = 'employment', + Education = 'education', +} diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index e7574b81b..a00be34ad 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -21,6 +21,7 @@ export * from './nodes/create-project-form.model'; export * from './nodes/nodes-json-api.model'; export * from './paginated-data.model'; export * from './pagination-links.model'; +export * from './profile-settings-update.model'; export * from './project-metadata-update-payload.model'; export * from './query-params.model'; export * from './registration'; diff --git a/src/app/shared/models/profile-settings-update.model.ts b/src/app/shared/models/profile-settings-update.model.ts new file mode 100644 index 000000000..8cf1afbb1 --- /dev/null +++ b/src/app/shared/models/profile-settings-update.model.ts @@ -0,0 +1,4 @@ +import { User } from '@osf/core/models'; +import { Education, Employment, Social } from '@osf/shared/models'; + +export type ProfileSettingsUpdate = Partial[] | Partial[] | Partial | Partial; diff --git a/src/app/shared/models/user/education.model.ts b/src/app/shared/models/user/education.model.ts index 311bb9d5a..785898633 100644 --- a/src/app/shared/models/user/education.model.ts +++ b/src/app/shared/models/user/education.model.ts @@ -1,10 +1,10 @@ export interface Education { + institution: string; + department: string; degree: string; + startMonth: number; + startYear: number; + endMonth: number | null; endYear: number | null; ongoing: boolean; - endMonth: number | null; - startYear: number; - department: string; - startMonth: number; - institution: string; } diff --git a/src/app/shared/models/user/employment.model.ts b/src/app/shared/models/user/employment.model.ts index 2ae539265..581eabac3 100644 --- a/src/app/shared/models/user/employment.model.ts +++ b/src/app/shared/models/user/employment.model.ts @@ -1,10 +1,10 @@ export interface Employment { title: string; - startYear: string | number; + institution: string; + department: string; startMonth: number; - endYear: number | null; + startYear: string | number; endMonth: number | null; + endYear: number | null; ongoing: boolean; - department: string; - institution: string; } diff --git a/src/app/shared/pipes/citation-format.pipe.ts b/src/app/shared/pipes/citation-format.pipe.ts new file mode 100644 index 000000000..e91128573 --- /dev/null +++ b/src/app/shared/pipes/citation-format.pipe.ts @@ -0,0 +1,53 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { User } from '@osf/core/models'; + +import { GENERATIONAL_SUFFIXES, ORDINAL_SUFFIXES } from '../constants/citation-suffix.const'; + +@Pipe({ + name: 'citationFormat', +}) +export class CitationFormatPipe implements PipeTransform { + transform(user: Partial | null | undefined, format: 'apa' | 'mla' = 'apa'): string { + if (!user) return ''; + + const familyName = user.familyName ?? ''; + const givenName = user.givenName ?? ''; + const middleInitials = this.getInitials(user.middleNames); + const suffix = user.suffix ? `, ${this.formatSuffix(user.suffix)}` : ''; + + let cite = ''; + + if (format === 'apa') { + const initials = [this.getInitials(givenName), middleInitials].filter(Boolean).join(' '); + cite = `${familyName}, ${initials}${suffix}`; + } else { + cite = `${familyName}, ${givenName} ${middleInitials}${suffix}`.trim(); + } + + return cite.endsWith('.') ? cite : `${cite}.`; + } + + private getInitials(names?: string): string { + return (names || '') + .trim() + .split(/\s+/) + .map((n) => (/^[a-z]/i.test(n) ? `${n[0].toUpperCase()}.` : '')) + .filter(Boolean) + .join(' '); + } + + private formatSuffix(suffix: string): string { + const lower = suffix.toLowerCase(); + + if (GENERATIONAL_SUFFIXES.includes(lower)) { + return `${lower.charAt(0).toUpperCase() + lower.slice(1)}.`; + } + + if (ORDINAL_SUFFIXES.includes(lower)) { + return lower.toUpperCase(); + } + + return suffix; + } +} diff --git a/src/app/shared/pipes/index.ts b/src/app/shared/pipes/index.ts index 6e3ada82f..b9ee0e97c 100644 --- a/src/app/shared/pipes/index.ts +++ b/src/app/shared/pipes/index.ts @@ -1,3 +1,4 @@ +export { CitationFormatPipe } from './citation-format.pipe'; export { DecodeHtmlPipe } from './decode-html.pipe'; export { FileSizePipe } from './file-size.pipe'; export { InterpolatePipe } from './interpolate.pipe'; diff --git a/src/app/shared/utils/custom-form-validators.helper.ts b/src/app/shared/utils/custom-form-validators.helper.ts index ebe77695d..7afc05632 100644 --- a/src/app/shared/utils/custom-form-validators.helper.ts +++ b/src/app/shared/utils/custom-form-validators.helper.ts @@ -50,4 +50,16 @@ export class CustomValidators { return null; }; } + + static dateRangeValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { + const start = control.get('startDate')?.value; + const end = control.get('endDate')?.value; + + if (!start || !end) return null; + + const startDate = new Date(start); + const endDate = new Date(end); + + return endDate > startDate ? null : { dateRangeInvalid: true }; + }; } diff --git a/src/app/shared/utils/form-validation.helper.ts b/src/app/shared/utils/form-validation.helper.ts new file mode 100644 index 000000000..a102cdbfe --- /dev/null +++ b/src/app/shared/utils/form-validation.helper.ts @@ -0,0 +1,26 @@ +import { AbstractControl, FormGroup } from '@angular/forms'; + +export class FormValidationHelper { + static hasError(control: AbstractControl | null, errorType: string): boolean { + return control?.errors?.[errorType] && control?.touched === true; + } + + static getErrorClass(control: AbstractControl | null, formErrors?: Record): string { + const hasControlError = control?.invalid && control?.touched; + const hasFormError = formErrors && Object.values(formErrors).some((error) => error); + + return hasControlError || hasFormError ? 'ng-invalid ng-dirty' : ''; + } + + static getFormControl(form: FormGroup, controlName: string): AbstractControl | null { + return form.get(controlName); + } + + static isFieldTouched(control: AbstractControl | null): boolean { + return control?.touched === true; + } + + static isFieldInvalid(control: AbstractControl | null): boolean { + return control?.invalid === true; + } +} diff --git a/src/app/shared/utils/index.ts b/src/app/shared/utils/index.ts index 64852254f..e3597af49 100644 --- a/src/app/shared/utils/index.ts +++ b/src/app/shared/utils/index.ts @@ -8,6 +8,7 @@ export * from './custom-form-validators.helper'; export * from './default-confirmation-config.helper'; export * from './find-changed-fields'; export * from './find-changed-items.helper'; +export * from './form-validation.helper'; export * from './get-resource-types.helper'; export * from './pie-chart-palette'; export * from './search-pref-to-json-api-query-params.helper'; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 50ea68b0b..aaaddf045 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -101,6 +101,10 @@ "header": "You Might Loose Unsaved Changes", "message": "Are you sure you want to proceed?" }, + "discardChangesDialog": { + "header": "Discard changes", + "message": "Are you sure you want to discard your unsaved changes?" + }, "links": { "clickHere": "Click here", "helpGuide": "Help Guide", @@ -1291,7 +1295,7 @@ "description": "Receive helpful tips on how to make the most of the OSF, up to once per week." } }, - "saveButton": "Save" + "successUpdate": "Email preferences successfully updated." }, "notificationPreferences": { "title": "Configure Notification Preferences", @@ -1302,7 +1306,8 @@ "files": "Files updated", "mentions": "Mentions added", "preprints": "Preprint submissions updated" - } + }, + "successUpdate": "Notification preferences successfully updated." } }, "addons": { @@ -1410,52 +1415,53 @@ "education": "Education" }, "name": { - "description": "Your full name is the name that will be displayed in your profile. To control the way your name will appear in citations, you can use the \"Auto-fill\" button to automatically infer your first name, last name, etc., or edit the fields directly below.", + "description": "Your full name is the name that will be displayed in your profile. To control the way your name will appear in citations, you can edit the fields directly below.", "fullName": "Full Name", "autoFill": "Auto-Fill", "givenName": "Given Name", - "middleNames": "Middle Name(S) (Optional)", + "middleNames": "Middle Name(s) (Optional)", "familyName": "Family Name", "suffix": "Suffix (Optional)", "citationPreview": { "title": "Citation Preview", "style": "Style:", - "citationFormat": "Citation format:" - } + "citationFormat": "Citation format:", + "apa": "APA", + "mla": "MLA" + }, + "successUpdate": "Settings successfully updated." }, "social": { "title": "Social Link {{index}}", "remove": "Remove", "socialOutput": "Social output", "webAddress": "Web Address", - "addMore": "Add One More" + "addMore": "Add One More", + "successUpdate": "Social successfully updated." }, "employment": { "title": "Position {{index}}", - "remove": "Remove", "jobTitle": "Job Title", "department": "Department / Institute (Optional)", "institution": "Institution / Employer", "startDate": "Start Date", "endDate": "End Date", "presentlyEmployed": "Presently employed", - "addPosition": "Add Position" + "addPosition": "Add Position", + "successUpdate": "Employment successfully updated." }, "education": { "title": "Education {{index}}", - "remove": "Remove", "institution": "Institution", "department": "Department", "degree": "Degree", "startDate": "Start Date", "endDate": "End Date", "ongoing": "Ongoing", - "addMore": "Add One More" + "addMore": "Add One More", + "successUpdate": "Education successfully updated." }, - "common": { - "discardChanges": "Discard Changes", - "save": "Save" - } + "endDateError": "End date must be greater than start date." }, "accountSettings": { "title": "Account Settings", @@ -1528,13 +1534,18 @@ "title": "Change Password", "form": { "oldPassword": "Old password", + "oldPasswordPlaceholder": "Enter your current password", "newPassword": "New password", + "newPasswordPlaceholder": "Enter your new password", "confirmPassword": "Confirm password", + "confirmPasswordPlaceholder": "Confirm your new password", "passwordRequirements": "Your password needs to be at least 8 characters long, include both lower- and upper-case characters, and have at least one number or special character" }, "validation": { "oldPasswordRequired": "Old password is required", "newPasswordRequired": "New password is required", + "newPasswordMinLength": "Password must be at least 8 characters long.", + "newPasswordPattern": "Password must include lowercase, uppercase, and at least one number or special character.", "confirmPasswordRequired": "Please confirm your password", "passwordsDoNotMatch": "Passwords do not match", "sameAsOldPassword": "New password must be different from old password" @@ -1543,6 +1554,7 @@ "update": "Update" }, "messages": { + "success": "Password updated successfully.", "error": "Failed to update password. Please try again." } }, diff --git a/src/assets/styles/overrides/card.scss b/src/assets/styles/overrides/card.scss index ba8032d3b..4a8420624 100644 --- a/src/assets/styles/overrides/card.scss +++ b/src/assets/styles/overrides/card.scss @@ -5,10 +5,6 @@ --p-card-border-radius: 0.5rem; } -.mobile .p-card .p-card-body { - --p-card-body-padding: 0.85rem; -} - .no-padding-card { --p-card-body-padding: 0; }