From c661e5a602a3160c40c808b21a6eab27ca806886 Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 17 Jul 2025 13:32:08 +0300 Subject: [PATCH 01/11] fix(change password): updated change password --- .../change-password.component.html | 97 +++++++++++-------- .../change-password.component.ts | 83 ++++++++++++---- .../store/account-settings.actions.ts | 9 ++ .../store/account-settings.state.ts | 8 ++ .../shared/utils/form-validation.helper.ts | 26 +++++ src/app/shared/utils/index.ts | 1 + src/assets/i18n/en.json | 6 ++ 7 files changed, 171 insertions(+), 59 deletions(-) create mode 100644 src/app/shared/utils/form-validation.helper.ts 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/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/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 cdc50bc64..890db4904 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1528,13 +1528,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 +1548,7 @@ "update": "Update" }, "messages": { + "success": "Password updated successfully.", "error": "Failed to update password. Please try again." } }, From 01b1fee43f688040e5f660b71198195ae044f573 Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 17 Jul 2025 15:00:01 +0300 Subject: [PATCH 02/11] fix(notifications): updated notifications --- .../notifications.component.html | 21 +++++---- .../notifications.component.scss | 19 +------- .../notifications/notifications.component.ts | 18 +++++--- .../store/notification-subscription.model.ts | 15 ++++++- .../notification-subscription.selectors.ts | 8 ++-- .../store/notification-subscription.state.ts | 44 +++++++++---------- src/assets/i18n/en.json | 5 ++- 7 files changed, 66 insertions(+), 64 deletions(-) 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/assets/i18n/en.json b/src/assets/i18n/en.json index 890db4904..7dee41230 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1291,7 +1291,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 +1302,8 @@ "files": "Files updated", "mentions": "Mentions added", "preprints": "Preprint submissions updated" - } + }, + "successUpdate": "Notification preferences successfully updated." } }, "addons": { From 40033893eeed79e6869ced8e4107b7c9068430e2 Mon Sep 17 00:00:00 2001 From: nsemets Date: Mon, 21 Jul 2025 13:51:12 +0300 Subject: [PATCH 03/11] fix(profile-settings): updates --- .cursor/rules/angular.mdc | 105 ++++++++++++++ .cursor/rules/cursor-rules.mdc | 134 ++++++++++++++++++ .cursor/rules/self-improve.mdc | 85 +++++++++++ .../citation-preview.component.html | 42 ++++++ .../citation-preview.component.scss | 3 + .../citation-preview.component.spec.ts | 22 +++ .../citation-preview.component.ts | 17 +++ .../profile-settings/components/index.ts | 2 + .../name-form/name-form.component.html | 61 ++++++++ .../name-form/name-form.component.scss | 3 + .../name-form/name-form.component.spec.ts | 22 +++ .../name-form/name-form.component.ts | 106 ++++++++++++++ .../components/name/name.component.html | 108 ++------------ .../components/name/name.component.scss | 25 ---- .../components/name/name.component.ts | 83 ++++++++--- .../services/profile-settings.api.service.ts | 4 +- .../store/profile-settings.state.ts | 8 +- .../shared/constants/citation-suffix.const.ts | 2 + src/app/shared/pipes/citation-format.pipe.ts | 53 +++++++ src/app/shared/pipes/index.ts | 1 + src/assets/i18n/en.json | 9 +- src/assets/styles/overrides/card.scss | 4 - 22 files changed, 745 insertions(+), 154 deletions(-) create mode 100644 .cursor/rules/angular.mdc create mode 100644 .cursor/rules/cursor-rules.mdc create mode 100644 .cursor/rules/self-improve.mdc create mode 100644 src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.html create mode 100644 src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.scss create mode 100644 src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.spec.ts create mode 100644 src/app/features/settings/profile-settings/components/citation-preview/citation-preview.component.ts create mode 100644 src/app/features/settings/profile-settings/components/name-form/name-form.component.html create mode 100644 src/app/features/settings/profile-settings/components/name-form/name-form.component.scss create mode 100644 src/app/features/settings/profile-settings/components/name-form/name-form.component.spec.ts create mode 100644 src/app/features/settings/profile-settings/components/name-form/name-form.component.ts create mode 100644 src/app/shared/constants/citation-suffix.const.ts create mode 100644 src/app/shared/pipes/citation-format.pipe.ts diff --git a/.cursor/rules/angular.mdc b/.cursor/rules/angular.mdc new file mode 100644 index 000000000..c45ae80fa --- /dev/null +++ b/.cursor/rules/angular.mdc @@ -0,0 +1,105 @@ +--- +alwaysApply: false +--- + +**You are an Angular, SASS, and TypeScript expert focused on creating scalable and high-performance web applications. Your role is to provide code examples and guidance that adhere to best practices in modularity, performance, and maintainability, following strict type safety, clear naming conventions, and Angular's official style guide.** + +**Key Development Principles** + +1. **Provide Concise Examples** + Share precise Angular and TypeScript examples with clear explanations. + +2. **Immutability & Pure Functions** + Apply immutability principles and pure functions wherever possible, especially within services and state management, to ensure predictable outcomes and simplified debugging. + +3. **Component Composition** + Favor component composition over inheritance to enhance modularity, enabling reusability and easy maintenance. + +4. **Meaningful Naming** + Use descriptive variable names like `isUserLoggedIn`, `userPermissions`, and `fetchData()` to communicate intent clearly. + +5. **File Naming** + Enforce kebab-case naming for files (e.g., `user-profile.component.ts`) and match Angular's conventions for file suffixes (e.g., `.component.ts`, `.service.ts`, etc.). + +**Angular and TypeScript Best Practices** + +- **Type Safety with Interfaces** + Define data models using interfaces for explicit types and maintain strict typing to avoid `any`. + +- **Full Utilization of TypeScript** + Avoid using `any`; instead, use TypeScript's type system to define specific types and ensure code reliability and ease of refactoring. + +- **Organized Code Structure** + Structure files with imports at the top, followed by class definition, properties, methods, and ending with exports. + +- **Optional Chaining & Nullish Coalescing** + Leverage optional chaining (`?.`) and nullish coalescing (`??`) to prevent null/undefined errors elegantly. + +- **Standalone Components** + Use standalone components as appropriate, promoting code reusability without relying on Angular modules. Avoid adding the standalone flag, as it defaults to true. + +- **Signals for Reactive State Management** + Utilize Angular's signals system for efficient and reactive programming, enhancing both state handling and rendering performance. + +- **Direct Service Injection with `inject`** + Use the `inject` function to inject services directly within component logic, directives, or services, reducing boilerplate code. + +**File Structure and Naming Conventions** + +- **Component Files**: `*.component.ts` +- **Service Files**: `*.service.ts` +- **Directive Files**: `*.directive.ts` +- **Pipe Files**: `*.pipe.ts` +- **Test Files**: `*.spec.ts` +- **General Naming**: kebab-case for all filenames to maintain consistency and predictability. + +**Coding Standards** + +- Use single quotes (`'`) for string literals. +- Use 2-space indentation. +- Avoid trailing whitespace and unused variables. +- Prefer `const` for constants and immutable variables. +- Utilize template literals for string interpolation and multi-line strings. + +**Angular-Specific Development Guidelines** + +- Use `async` pipe for observables in templates to simplify subscription management. +- Enable lazy loading for feature components, optimizing initial load times. +- Ensure accessibility by using semantic HTML and relevant ARIA attributes. +- Use Angular's signals system for efficient reactive state management. +- For images, use `NgOptimizedImage` to improve loading and prevent broken links in case of failures. +- Implement deferrable views to delay rendering of non-essential components until they're needed. +- Use `@for`, `@if`, `@switch` instead of `ngFor`, `ngIf`, `ngSwitch` directives. +- Use signal based approach for `input`, `output`, `viewChild` and etc. + +**Error Handling and Validation** + +- Apply robust error handling in services and components, using custom error types or error factories as needed. +- Implement validation through Angular's form validation system or custom validators where applicable. + +**Testing and Code Quality** + +- Add `.spec.ts` file to components only. +- Cover only 'should create' test. +- Use jest and ng mocks for testing. + +**Performance Optimization** + +- Apply pure pipes for computationally heavy operations, ensuring that recalculations occur only when inputs change. +- Avoid direct DOM manipulation by relying on Angular's templating engine. +- Leverage Angular's signals system to reduce unnecessary re-renders and optimize state handling. +- Use `NgOptimizedImage` for faster, more efficient image loading. + +**Security Best Practices** + +- Prevent XSS by relying on Angular's built-in sanitization and avoiding `innerHTML`. +- Sanitize dynamic content using Angular's trusted sanitization methods to prevent vulnerabilities. + +**Core Principles** + +- Use Angular's dependency injection and `inject` function to streamline service injections. +- Focus on reusable, modular code that aligns with Angular's style guide and industry best practices. +- Continuously optimize for core Web Vitals, especially Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS). + +**Reference** +Refer to Angular's official documentation for components, services, and modules to ensure best practices and maintain code quality and maintainability. diff --git a/.cursor/rules/cursor-rules.mdc b/.cursor/rules/cursor-rules.mdc new file mode 100644 index 000000000..1556eb358 --- /dev/null +++ b/.cursor/rules/cursor-rules.mdc @@ -0,0 +1,134 @@ +--- +description: How to add or edit Cursor rules in your project +globs: +alwaysApply: false +--- +# Cursor Rules Management Guide + +## Rule Structure Format + +Every cursor rule must follow this exact metadata and content structure: + +````markdown +--- +description: Short description of the rule's purpose +globs: optional/path/pattern/**/* +alwaysApply: false +--- +# Rule Title + +Main content explaining the rule with markdown formatting. + +1. Step-by-step instructions +2. Code examples +3. Guidelines + +Example: +```typescript +// Good +function goodExample() { + // Correct implementation +} + +// Bad example +function badExample() { + // Incorrect implementation +} +``` +```` + +## File Organization + +### Required Location + +All cursor rule files **must** be placed in: + +``` +PROJECT_ROOT/.cursor/rules/ +``` + +### Directory Structure + +``` +PROJECT_ROOT/ +├── .cursor/ +│ └── rules/ +│ ├── your-rule-name.mdc +│ ├── another-rule.mdc +│ └── cursor-rules.mdc +└── ... +``` + +### Naming Conventions + +- Use **kebab-case** for all filenames +- Always use **.mdc** extension +- Make names **descriptive** of the rule's purpose +- Examples: `typescript-style.mdc`, `tailwind-styling.mdc`, `mdx-documentation.mdc` + +## Content Guidelines + +### Writing Effective Rules + +1. **Be specific and actionable** - Provide clear instructions +2. **Include code examples** - Show both good and bad practices +3. **Reference existing files** - Use `@filename.ext` format +4. **Keep it focused** - One rule per concern/pattern +5. **Add context** - Explain why the rule exists + +### Code Examples Format + +```typescript +// ✅ Good: Clear and follows conventions +function processUser({ id, name }: { id: string; name: string }) { + return { id, displayName: name }; +} + +// ❌ Bad: Unclear parameter passing +function processUser(id: string, name: string) { + return { id, displayName: name }; +} +``` + +### File References + +When referencing project files in rules, use this pattern to mention other files: + +```markdown +[file.tsx](mdc:path/to/file.tsx) +``` + +## Forbidden Locations + +**Never** place rule files in: +- Project root directory +- Any subdirectory outside `.cursor/rules/` +- Component directories +- Source code folders +- Documentation folders + +## Rule Categories + +Organize rules by purpose: +- **Code Style**: `typescript-style.mdc`, `css-conventions.mdc` +- **Architecture**: `component-patterns.mdc`, `folder-structure.mdc` +- **Documentation**: `mdx-documentation.mdc`, `readme-format.mdc` +- **Tools**: `testing-patterns.mdc`, `build-config.mdc` +- **Meta**: `cursor-rules.mdc`, `self-improve.mdc` + +## Best Practices + +### Rule Creation Checklist +- [ ] File placed in `.cursor/rules/` directory +- [ ] Filename uses kebab-case with `.mdc` extension +- [ ] Includes proper metadata section +- [ ] Contains clear title and sections +- [ ] Provides both good and bad examples +- [ ] References relevant project files +- [ ] Follows consistent formatting + +### Maintenance +- **Review regularly** - Keep rules up to date with codebase changes +- **Update examples** - Ensure code samples reflect current patterns +- **Cross-reference** - Link related rules together +- **Document changes** - Update rules when patterns evolve diff --git a/.cursor/rules/self-improve.mdc b/.cursor/rules/self-improve.mdc new file mode 100644 index 000000000..8a4c90579 --- /dev/null +++ b/.cursor/rules/self-improve.mdc @@ -0,0 +1,85 @@ +--- +description: Guidelines for continuously improving Cursor rules based on emerging code patterns and best practices. +globs: **/* +alwaysApply: true +--- + +## Rule Improvement Triggers + +- New code patterns not covered by existing rules +- Repeated similar implementations across files +- Common error patterns that could be prevented +- New libraries or tools being used consistently +- Emerging best practices in the codebase + +# Analysis Process: +- Compare new code with existing rules +- Identify patterns that should be standardized +- Look for references to external documentation +- Check for consistent error handling patterns +- Monitor test patterns and coverage + +# Rule Updates: + +- **Add New Rules When:** + - A new technology/pattern is used in 3+ files + - Common bugs could be prevented by a rule + - Code reviews repeatedly mention the same feedback + - New security or performance patterns emerge + +- **Modify Existing Rules When:** + - Better examples exist in the codebase + - Additional edge cases are discovered + - Related rules have been updated + - Implementation details have changed + +- **Example Pattern Recognition:** + + ```typescript + // If you see repeated patterns like: + const data = await prisma.user.findMany({ + select: { id: true, email: true }, + where: { status: 'ACTIVE' } + }); + + // Consider adding to [prisma.mdc](mdc:shipixen/.cursor/rules/prisma.mdc): + // - Standard select fields + // - Common where conditions + // - Performance optimization patterns + ``` + +- **Rule Quality Checks:** +- Rules should be actionable and specific +- Examples should come from actual code +- References should be up to date +- Patterns should be consistently enforced + +## Continuous Improvement: + +- Monitor code review comments +- Track common development questions +- Update rules after major refactors +- Add links to relevant documentation +- Cross-reference related rules + +## Rule Deprecation + +- Mark outdated patterns as deprecated +- Remove rules that no longer apply +- Update references to deprecated rules +- Document migration paths for old patterns + +## Documentation Updates: + +- Keep examples synchronized with code +- Update references to external docs +- Maintain links between related rules +- Document breaking changes + +Follow [cursor-rules.mdc](mdc:.cursor/rules/cursor-rules.mdc) for proper rule formatting and structure. + + + + + + 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/index.ts b/src/app/features/settings/profile-settings/components/index.ts index e4339768a..918e197e1 100644 --- a/src/app/features/settings/profile-settings/components/index.ts +++ b/src/app/features/settings/profile-settings/components/index.ts @@ -1,4 +1,6 @@ +export { CitationPreviewComponent } from './citation-preview/citation-preview.component'; export { EducationComponent } from './education/education.component'; export { EmploymentComponent } from './employment/employment.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..607c290e1 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/name-form/name-form.component.html @@ -0,0 +1,61 @@ +
+

+ {{ '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..9412bd6e5 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/name-form/name-form.component.ts @@ -0,0 +1,106 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; + +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 { GENERATIONAL_SUFFIXES, ORDINAL_SUFFIXES } from '@osf/shared/constants/citation-suffix.const'; + +import { NameForm } from '../../models'; + +@Component({ + selector: 'osf-name-form', + imports: [Button, 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; + + autoFill() { + const fullName = this.form().controls.fullName.value?.trim(); + + if (!fullName) { + return; + } + + const parsed = this.parseFullName(fullName); + + this.form().patchValue({ + givenName: parsed.givenName, + middleNames: parsed.middleNames || '', + familyName: parsed.familyName, + suffix: parsed.suffix || '', + }); + } + + private parseFullName(fullName: string): { + givenName: string; + middleNames?: string; + familyName: string; + suffix?: string; + } { + const nameParts = fullName.split(/\s+/).filter((part) => part.length > 0); + + if (nameParts.length === 0) { + return { givenName: '', familyName: '' }; + } + + if (nameParts.length === 1) { + return { + givenName: '', + familyName: nameParts[0], + }; + } + + let suffix: string | undefined; + const workingParts = [...nameParts]; + + const lastPart = workingParts[workingParts.length - 1]?.toLowerCase(); + if (lastPart && this.isValidSuffix(lastPart)) { + suffix = workingParts.pop(); + } + + if (workingParts.length === 1) { + return { + givenName: '', + familyName: workingParts[0], + suffix, + }; + } + + const givenName = workingParts[0]; + const familyName = workingParts[workingParts.length - 1]; + const middleNames = workingParts.slice(1, -1).join(' ') || undefined; + + return { + givenName, + middleNames, + familyName, + suffix, + }; + } + + private isValidSuffix(suffix: string): boolean { + const lower = suffix.toLowerCase(); + + if ( + GENERATIONAL_SUFFIXES.includes(lower) || + (lower.endsWith('.') && GENERATIONAL_SUFFIXES.includes(lower.slice(0, -1))) + ) { + return true; + } + + if (ORDINAL_SUFFIXES.includes(lower)) { + return true; + } + + return false; + } +} 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..e18987da0 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..1bb01f13c 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 { 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,45 @@ 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(ProfileSettingsSelectors.user); + 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(); + this.updateForm(user); + this.updatePreviewUser(); + }); + + 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 +74,34 @@ export class NameComponent { suffix, }, }) - ); + .subscribe(() => { + this.loaderService.hide(); + this.toastService.showSuccess('settings.profileSettings.name.successUpdate'); + }); + } + + discardChanges() { + const user = this.currentUser(); + 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/services/profile-settings.api.service.ts b/src/app/features/settings/profile-settings/services/profile-settings.api.service.ts index 4f13a9df1..978c97d49 100644 --- 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 @@ -1,6 +1,6 @@ import { inject, Injectable } from '@angular/core'; -import { JsonApiResponse, UserGetResponse } from '@osf/core/models'; +import { UserGetResponse } from '@osf/core/models'; import { JsonApiService } from '@osf/core/services'; import { ProfileSettingsStateModel, ProfileSettingsUpdate } from '../store'; @@ -16,7 +16,7 @@ export class ProfileSettingsApiService { patchUserSettings(userId: string, key: keyof ProfileSettingsStateModel, data: ProfileSettingsUpdate) { const patchedData = { [key]: data }; - return this.jsonApiService.patch>(`${environment.apiUrl}/users/${userId}/`, { + 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/profile-settings.state.ts b/src/app/features/settings/profile-settings/store/profile-settings.state.ts index e2c5e7f64..9de708f20 100644 --- a/src/app/features/settings/profile-settings/store/profile-settings.state.ts +++ b/src/app/features/settings/profile-settings/store/profile-settings.state.ts @@ -62,7 +62,7 @@ export class ProfileSettingsState { tap((response) => { ctx.patchState({ ...state, - employment: response.data.attributes.employment, + employment: response.attributes.employment, }); }) ); @@ -86,7 +86,7 @@ export class ProfileSettingsState { tap((response) => { ctx.patchState({ ...state, - education: response.data.attributes.education, + education: response.attributes.education, }); }) ); @@ -107,7 +107,7 @@ export class ProfileSettingsState { tap((response) => { ctx.patchState({ ...state, - user: response.data.attributes, + user: response.attributes, }); }) ); @@ -138,7 +138,7 @@ export class ProfileSettingsState { tap((response) => { ctx.patchState({ ...state, - social: response.data.attributes.social, + social: response.attributes.social, }); }) ); 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/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/assets/i18n/en.json b/src/assets/i18n/en.json index 261235d6e..a64f106be 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1415,14 +1415,17 @@ "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}}", 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; } From 4bf33df8456438d114f8ef8bb86d0601fcffc97a Mon Sep 17 00:00:00 2001 From: nsemets Date: Mon, 21 Jul 2025 18:25:42 +0300 Subject: [PATCH 04/11] fix(removed): removed some files --- .cursor/rules/angular.mdc | 105 -------------------------- .cursor/rules/cursor-rules.mdc | 134 --------------------------------- .cursor/rules/self-improve.mdc | 85 --------------------- 3 files changed, 324 deletions(-) delete mode 100644 .cursor/rules/angular.mdc delete mode 100644 .cursor/rules/cursor-rules.mdc delete mode 100644 .cursor/rules/self-improve.mdc diff --git a/.cursor/rules/angular.mdc b/.cursor/rules/angular.mdc deleted file mode 100644 index c45ae80fa..000000000 --- a/.cursor/rules/angular.mdc +++ /dev/null @@ -1,105 +0,0 @@ ---- -alwaysApply: false ---- - -**You are an Angular, SASS, and TypeScript expert focused on creating scalable and high-performance web applications. Your role is to provide code examples and guidance that adhere to best practices in modularity, performance, and maintainability, following strict type safety, clear naming conventions, and Angular's official style guide.** - -**Key Development Principles** - -1. **Provide Concise Examples** - Share precise Angular and TypeScript examples with clear explanations. - -2. **Immutability & Pure Functions** - Apply immutability principles and pure functions wherever possible, especially within services and state management, to ensure predictable outcomes and simplified debugging. - -3. **Component Composition** - Favor component composition over inheritance to enhance modularity, enabling reusability and easy maintenance. - -4. **Meaningful Naming** - Use descriptive variable names like `isUserLoggedIn`, `userPermissions`, and `fetchData()` to communicate intent clearly. - -5. **File Naming** - Enforce kebab-case naming for files (e.g., `user-profile.component.ts`) and match Angular's conventions for file suffixes (e.g., `.component.ts`, `.service.ts`, etc.). - -**Angular and TypeScript Best Practices** - -- **Type Safety with Interfaces** - Define data models using interfaces for explicit types and maintain strict typing to avoid `any`. - -- **Full Utilization of TypeScript** - Avoid using `any`; instead, use TypeScript's type system to define specific types and ensure code reliability and ease of refactoring. - -- **Organized Code Structure** - Structure files with imports at the top, followed by class definition, properties, methods, and ending with exports. - -- **Optional Chaining & Nullish Coalescing** - Leverage optional chaining (`?.`) and nullish coalescing (`??`) to prevent null/undefined errors elegantly. - -- **Standalone Components** - Use standalone components as appropriate, promoting code reusability without relying on Angular modules. Avoid adding the standalone flag, as it defaults to true. - -- **Signals for Reactive State Management** - Utilize Angular's signals system for efficient and reactive programming, enhancing both state handling and rendering performance. - -- **Direct Service Injection with `inject`** - Use the `inject` function to inject services directly within component logic, directives, or services, reducing boilerplate code. - -**File Structure and Naming Conventions** - -- **Component Files**: `*.component.ts` -- **Service Files**: `*.service.ts` -- **Directive Files**: `*.directive.ts` -- **Pipe Files**: `*.pipe.ts` -- **Test Files**: `*.spec.ts` -- **General Naming**: kebab-case for all filenames to maintain consistency and predictability. - -**Coding Standards** - -- Use single quotes (`'`) for string literals. -- Use 2-space indentation. -- Avoid trailing whitespace and unused variables. -- Prefer `const` for constants and immutable variables. -- Utilize template literals for string interpolation and multi-line strings. - -**Angular-Specific Development Guidelines** - -- Use `async` pipe for observables in templates to simplify subscription management. -- Enable lazy loading for feature components, optimizing initial load times. -- Ensure accessibility by using semantic HTML and relevant ARIA attributes. -- Use Angular's signals system for efficient reactive state management. -- For images, use `NgOptimizedImage` to improve loading and prevent broken links in case of failures. -- Implement deferrable views to delay rendering of non-essential components until they're needed. -- Use `@for`, `@if`, `@switch` instead of `ngFor`, `ngIf`, `ngSwitch` directives. -- Use signal based approach for `input`, `output`, `viewChild` and etc. - -**Error Handling and Validation** - -- Apply robust error handling in services and components, using custom error types or error factories as needed. -- Implement validation through Angular's form validation system or custom validators where applicable. - -**Testing and Code Quality** - -- Add `.spec.ts` file to components only. -- Cover only 'should create' test. -- Use jest and ng mocks for testing. - -**Performance Optimization** - -- Apply pure pipes for computationally heavy operations, ensuring that recalculations occur only when inputs change. -- Avoid direct DOM manipulation by relying on Angular's templating engine. -- Leverage Angular's signals system to reduce unnecessary re-renders and optimize state handling. -- Use `NgOptimizedImage` for faster, more efficient image loading. - -**Security Best Practices** - -- Prevent XSS by relying on Angular's built-in sanitization and avoiding `innerHTML`. -- Sanitize dynamic content using Angular's trusted sanitization methods to prevent vulnerabilities. - -**Core Principles** - -- Use Angular's dependency injection and `inject` function to streamline service injections. -- Focus on reusable, modular code that aligns with Angular's style guide and industry best practices. -- Continuously optimize for core Web Vitals, especially Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS). - -**Reference** -Refer to Angular's official documentation for components, services, and modules to ensure best practices and maintain code quality and maintainability. diff --git a/.cursor/rules/cursor-rules.mdc b/.cursor/rules/cursor-rules.mdc deleted file mode 100644 index 1556eb358..000000000 --- a/.cursor/rules/cursor-rules.mdc +++ /dev/null @@ -1,134 +0,0 @@ ---- -description: How to add or edit Cursor rules in your project -globs: -alwaysApply: false ---- -# Cursor Rules Management Guide - -## Rule Structure Format - -Every cursor rule must follow this exact metadata and content structure: - -````markdown ---- -description: Short description of the rule's purpose -globs: optional/path/pattern/**/* -alwaysApply: false ---- -# Rule Title - -Main content explaining the rule with markdown formatting. - -1. Step-by-step instructions -2. Code examples -3. Guidelines - -Example: -```typescript -// Good -function goodExample() { - // Correct implementation -} - -// Bad example -function badExample() { - // Incorrect implementation -} -``` -```` - -## File Organization - -### Required Location - -All cursor rule files **must** be placed in: - -``` -PROJECT_ROOT/.cursor/rules/ -``` - -### Directory Structure - -``` -PROJECT_ROOT/ -├── .cursor/ -│ └── rules/ -│ ├── your-rule-name.mdc -│ ├── another-rule.mdc -│ └── cursor-rules.mdc -└── ... -``` - -### Naming Conventions - -- Use **kebab-case** for all filenames -- Always use **.mdc** extension -- Make names **descriptive** of the rule's purpose -- Examples: `typescript-style.mdc`, `tailwind-styling.mdc`, `mdx-documentation.mdc` - -## Content Guidelines - -### Writing Effective Rules - -1. **Be specific and actionable** - Provide clear instructions -2. **Include code examples** - Show both good and bad practices -3. **Reference existing files** - Use `@filename.ext` format -4. **Keep it focused** - One rule per concern/pattern -5. **Add context** - Explain why the rule exists - -### Code Examples Format - -```typescript -// ✅ Good: Clear and follows conventions -function processUser({ id, name }: { id: string; name: string }) { - return { id, displayName: name }; -} - -// ❌ Bad: Unclear parameter passing -function processUser(id: string, name: string) { - return { id, displayName: name }; -} -``` - -### File References - -When referencing project files in rules, use this pattern to mention other files: - -```markdown -[file.tsx](mdc:path/to/file.tsx) -``` - -## Forbidden Locations - -**Never** place rule files in: -- Project root directory -- Any subdirectory outside `.cursor/rules/` -- Component directories -- Source code folders -- Documentation folders - -## Rule Categories - -Organize rules by purpose: -- **Code Style**: `typescript-style.mdc`, `css-conventions.mdc` -- **Architecture**: `component-patterns.mdc`, `folder-structure.mdc` -- **Documentation**: `mdx-documentation.mdc`, `readme-format.mdc` -- **Tools**: `testing-patterns.mdc`, `build-config.mdc` -- **Meta**: `cursor-rules.mdc`, `self-improve.mdc` - -## Best Practices - -### Rule Creation Checklist -- [ ] File placed in `.cursor/rules/` directory -- [ ] Filename uses kebab-case with `.mdc` extension -- [ ] Includes proper metadata section -- [ ] Contains clear title and sections -- [ ] Provides both good and bad examples -- [ ] References relevant project files -- [ ] Follows consistent formatting - -### Maintenance -- **Review regularly** - Keep rules up to date with codebase changes -- **Update examples** - Ensure code samples reflect current patterns -- **Cross-reference** - Link related rules together -- **Document changes** - Update rules when patterns evolve diff --git a/.cursor/rules/self-improve.mdc b/.cursor/rules/self-improve.mdc deleted file mode 100644 index 8a4c90579..000000000 --- a/.cursor/rules/self-improve.mdc +++ /dev/null @@ -1,85 +0,0 @@ ---- -description: Guidelines for continuously improving Cursor rules based on emerging code patterns and best practices. -globs: **/* -alwaysApply: true ---- - -## Rule Improvement Triggers - -- New code patterns not covered by existing rules -- Repeated similar implementations across files -- Common error patterns that could be prevented -- New libraries or tools being used consistently -- Emerging best practices in the codebase - -# Analysis Process: -- Compare new code with existing rules -- Identify patterns that should be standardized -- Look for references to external documentation -- Check for consistent error handling patterns -- Monitor test patterns and coverage - -# Rule Updates: - -- **Add New Rules When:** - - A new technology/pattern is used in 3+ files - - Common bugs could be prevented by a rule - - Code reviews repeatedly mention the same feedback - - New security or performance patterns emerge - -- **Modify Existing Rules When:** - - Better examples exist in the codebase - - Additional edge cases are discovered - - Related rules have been updated - - Implementation details have changed - -- **Example Pattern Recognition:** - - ```typescript - // If you see repeated patterns like: - const data = await prisma.user.findMany({ - select: { id: true, email: true }, - where: { status: 'ACTIVE' } - }); - - // Consider adding to [prisma.mdc](mdc:shipixen/.cursor/rules/prisma.mdc): - // - Standard select fields - // - Common where conditions - // - Performance optimization patterns - ``` - -- **Rule Quality Checks:** -- Rules should be actionable and specific -- Examples should come from actual code -- References should be up to date -- Patterns should be consistently enforced - -## Continuous Improvement: - -- Monitor code review comments -- Track common development questions -- Update rules after major refactors -- Add links to relevant documentation -- Cross-reference related rules - -## Rule Deprecation - -- Mark outdated patterns as deprecated -- Remove rules that no longer apply -- Update references to deprecated rules -- Document migration paths for old patterns - -## Documentation Updates: - -- Keep examples synchronized with code -- Update references to external docs -- Maintain links between related rules -- Document breaking changes - -Follow [cursor-rules.mdc](mdc:.cursor/rules/cursor-rules.mdc) for proper rule formatting and structure. - - - - - - From 6fef967679de2da2ca05c2d1d8972a1a49dfe6e6 Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 22 Jul 2025 10:48:43 +0300 Subject: [PATCH 05/11] fix(profile-settings): updated state for profile settings --- .../components/header/header.component.ts | 2 +- .../core/constants/ngxs-states.constant.ts | 2 - src/app/core/services/user.service.ts | 10 ++ src/app/core/store/user/user.actions.ts | 33 +++- src/app/core/store/user/user.model.ts | 16 +- src/app/core/store/user/user.selectors.ts | 45 ++--- src/app/core/store/user/user.state.ts | 155 ++++++++++++++++-- .../file-metadata.component.html | 0 .../files/store/project-files.state.ts | 2 - .../connected-emails.component.html | 4 +- .../education/education.component.html | 10 +- .../education/education.component.ts | 27 ++- .../employment/employment.component.html | 14 +- .../employment/employment.component.ts | 19 ++- .../name-form/name-form.component.html | 8 - .../name-form/name-form.component.ts | 86 +--------- .../components/name/name.component.ts | 15 +- .../components/social/social.component.html | 4 +- .../components/social/social.component.scss | 3 - .../components/social/social.component.ts | 15 +- .../profile-settings/constants/index.ts | 1 + .../constants/limits.const.ts | 2 + .../profile-settings/services/index.ts | 1 - .../services/profile-settings.api.service.ts | 23 --- .../settings/profile-settings/store/index.ts | 4 - .../store/profile-settings.actions.ts | 30 ---- .../store/profile-settings.model.ts | 28 ---- .../store/profile-settings.selectors.ts | 29 ---- .../store/profile-settings.state.ts | 146 ----------------- .../files-tree/files-tree.component.ts | 6 - src/assets/i18n/en.json | 11 +- 31 files changed, 318 insertions(+), 433 deletions(-) delete mode 100644 src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.html create mode 100644 src/app/features/settings/profile-settings/constants/limits.const.ts delete mode 100644 src/app/features/settings/profile-settings/services/index.ts delete mode 100644 src/app/features/settings/profile-settings/services/profile-settings.api.service.ts delete mode 100644 src/app/features/settings/profile-settings/store/index.ts delete mode 100644 src/app/features/settings/profile-settings/store/profile-settings.actions.ts delete mode 100644 src/app/features/settings/profile-settings/store/profile-settings.model.ts delete mode 100644 src/app/features/settings/profile-settings/store/profile-settings.selectors.ts delete mode 100644 src/app/features/settings/profile-settings/store/profile-settings.state.ts 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/services/user.service.ts b/src/app/core/services/user.service.ts index 4daf67cf8..ec224d13f 100644 --- a/src/app/core/services/user.service.ts +++ b/src/app/core/services/user.service.ts @@ -33,4 +33,14 @@ export class UserService { .patch(`${environment.apiUrl}/users/${userId}/settings/`, request) .pipe(map((response) => UserMapper.fromUserSettingsGetResponse(response))); } + + updateUserProfile(userId: string, key: string, data: unknown): Observable { + const patchedData = { [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..3abbdadc9 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,37 @@ export class GetCurrentUserSettings { export class UpdateUserSettings { static readonly type = '[User] Update User Settings'; - constructor( public userId: string, public updatedUserSettings: UserSettings ) {} } + +export class UpdateUserProfile { + static readonly type = '[User] Update User Profile'; + constructor(public payload: { key: string; data: Partial | Education[] }) {} +} + +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..38370f237 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 { mapNameToDto } from '@osf/features/settings/profile-settings/models'; +import { removeNullable } from '@osf/shared/constants'; +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, + UpdateUserProfile, + 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,126 @@ export class UserState { }) ); } + + @Action(UpdateUserProfile) + updateUserProfile(ctx: StateContext, { payload }: UpdateUserProfile) { + const state = ctx.getState(); + const userId = state.currentUser.data?.id; + + if (!userId) { + return; + } + + // const withoutNulls = payload.data.map((item) => removeNullable(item)); + + return this.userService.updateUserProfile(userId, payload.key, payload.data).pipe( + tap((user) => { + ctx.patchState({ + currentUser: { + ...state.currentUser, + data: user, + }, + }); + }) + ); + } + + @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, '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, '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 = mapNameToDto(removeNullable(payload.user)); + + return this.userService.updateUserProfile(userId, '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, 'social', social).pipe( + tap((user) => { + ctx.patchState({ + currentUser: { + ...state.currentUser, + data: user, + }, + }); + }) + ); + } } diff --git a/src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.html b/src/app/features/project/files/components/file-detail/components/file-metadata/file-metadata.component.html deleted file mode 100644 index e69de29bb..000000000 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/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/profile-settings/components/education/education.component.html b/src/app/features/settings/profile-settings/components/education/education.component.html index fa1e4da90..25d96705e 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 @@ -9,7 +9,7 @@

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

@if (index !== 0) { -
+
+
+
+
@@ -81,6 +88,7 @@

[inputId]="'ongoing-' + index" name="ongoing" > + 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..838346c90 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 @@ -10,10 +10,13 @@ import { InputText } from 'primeng/inputtext'; import { ChangeDetectionStrategy, Component, effect, HostBinding, inject } from '@angular/core'; import { FormArray, FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { UpdateProfileSettingsEducation, UserSelectors } from '@osf/core/store/user'; import { Education } from '@osf/shared/models'; +import { LoaderService, ToastService } from '@osf/shared/services'; +import { CustomValidators } from '@osf/shared/utils'; +import { MAX_DATE, MIN_DATE } from '../../constants'; import { EducationForm } from '../../models'; -import { ProfileSettingsSelectors, UpdateProfileSettingsEducation } from '../../store'; @Component({ selector: 'osf-education', @@ -25,20 +28,28 @@ import { ProfileSettingsSelectors, UpdateProfileSettingsEducation } from '../../ export class EducationComponent { @HostBinding('class') classes = 'flex flex-column gap-5'; + maxDate = MAX_DATE; + minDate = MIN_DATE; + readonly fb = inject(FormBuilder); protected readonly educationForm = this.fb.group({ educations: this.fb.array([]) }); + private readonly loaderService = inject(LoaderService); + private readonly toastService = inject(ToastService); + 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], + institution: [education.institution, CustomValidators.requiredTrimmed()], department: [education.department], degree: [education.degree], startDate: [new Date(+education.startYear, education.startMonth - 1)], @@ -49,6 +60,7 @@ export class EducationComponent { : null, ongoing: [education.ongoing], }); + this.educations.push(newEducation); }); } @@ -65,7 +77,7 @@ export class EducationComponent { addEducation(): void { const newEducation = this.fb.group({ - institution: [''], + institution: ['', [CustomValidators.requiredTrimmed()]], department: [''], degree: [''], startDate: [null], @@ -89,7 +101,12 @@ export class EducationComponent { ongoing: education.ongoing, })) satisfies Education[]; - this.actions.updateProfileSettingsEducation({ education: formattedEducation }); + this.loaderService.show(); + + this.actions.updateProfileSettingsEducation({ education: formattedEducation }).subscribe(() => { + this.loaderService.hide(); + this.toastService.showSuccess('settings.profileSettings.education.successUpdate'); + }); } private setupDates( 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..aec2aa691 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 @@ -10,7 +10,7 @@

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

@if (index !== 0) { -
+
+
@@ -34,12 +35,15 @@

+

+
+

@@ -52,24 +56,31 @@

+

+
+
@@ -81,6 +92,7 @@

[inputId]="'presently-employed-' + index" name="presently-employed" > + 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..07006363f 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 @@ -10,11 +10,13 @@ import { InputText } from 'primeng/inputtext'; import { ChangeDetectionStrategy, Component, effect, HostBinding, inject } from '@angular/core'; import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { UpdateProfileSettingsEmployment, UserSelectors } from '@osf/core/store/user'; import { Employment } from '@osf/shared/models'; +import { LoaderService, ToastService } from '@osf/shared/services'; import { CustomValidators } from '@osf/shared/utils'; +import { MAX_DATE, MIN_DATE } from '../../constants'; import { EmploymentForm } from '../../models'; -import { ProfileSettingsSelectors, UpdateProfileSettingsEmployment } from '../../store'; @Component({ selector: 'osf-employment', @@ -26,8 +28,14 @@ import { ProfileSettingsSelectors, UpdateProfileSettingsEmployment } from '../.. export class EmploymentComponent { @HostBinding('class') classes = 'flex flex-column gap-5'; + maxDate = MAX_DATE; + minDate = MIN_DATE; + + private readonly loaderService = inject(LoaderService); + private readonly toastService = inject(ToastService); + 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([]) }); @@ -94,7 +102,12 @@ export class EmploymentComponent { ongoing: !employment.ongoing, })) satisfies Employment[]; - this.actions.updateProfileSettingsEmployment({ employment: formattedEmployments }); + this.loaderService.show(); + + this.actions.updateProfileSettingsEmployment({ employment: formattedEmployments }).subscribe(() => { + this.loaderService.hide(); + this.toastService.showSuccess('settings.profileSettings.employment.successUpdate'); + }); } private setupDates( 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 index 607c290e1..2744d39e6 100644 --- 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 @@ -12,14 +12,6 @@ [maxLength]="inputLimits.fullName.maxLength" >

- -
- -
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 index 9412bd6e5..0b916296d 100644 --- 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 @@ -1,19 +1,16 @@ import { TranslatePipe } from '@ngx-translate/core'; -import { Button } from 'primeng/button'; - 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 { GENERATIONAL_SUFFIXES, ORDINAL_SUFFIXES } from '@osf/shared/constants/citation-suffix.const'; import { NameForm } from '../../models'; @Component({ selector: 'osf-name-form', - imports: [Button, ReactiveFormsModule, TranslatePipe, TextInputComponent], + imports: [ReactiveFormsModule, TranslatePipe, TextInputComponent], templateUrl: './name-form.component.html', styleUrl: './name-form.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -22,85 +19,4 @@ export class NameFormComponent { form = input.required>(); readonly inputLimits = InputLimits; - - autoFill() { - const fullName = this.form().controls.fullName.value?.trim(); - - if (!fullName) { - return; - } - - const parsed = this.parseFullName(fullName); - - this.form().patchValue({ - givenName: parsed.givenName, - middleNames: parsed.middleNames || '', - familyName: parsed.familyName, - suffix: parsed.suffix || '', - }); - } - - private parseFullName(fullName: string): { - givenName: string; - middleNames?: string; - familyName: string; - suffix?: string; - } { - const nameParts = fullName.split(/\s+/).filter((part) => part.length > 0); - - if (nameParts.length === 0) { - return { givenName: '', familyName: '' }; - } - - if (nameParts.length === 1) { - return { - givenName: '', - familyName: nameParts[0], - }; - } - - let suffix: string | undefined; - const workingParts = [...nameParts]; - - const lastPart = workingParts[workingParts.length - 1]?.toLowerCase(); - if (lastPart && this.isValidSuffix(lastPart)) { - suffix = workingParts.pop(); - } - - if (workingParts.length === 1) { - return { - givenName: '', - familyName: workingParts[0], - suffix, - }; - } - - const givenName = workingParts[0]; - const familyName = workingParts[workingParts.length - 1]; - const middleNames = workingParts.slice(1, -1).join(' ') || undefined; - - return { - givenName, - middleNames, - familyName, - suffix, - }; - } - - private isValidSuffix(suffix: string): boolean { - const lower = suffix.toLowerCase(); - - if ( - GENERATIONAL_SUFFIXES.includes(lower) || - (lower.endsWith('.') && GENERATIONAL_SUFFIXES.includes(lower.slice(0, -1))) - ) { - return true; - } - - if (ORDINAL_SUFFIXES.includes(lower)) { - return true; - } - - return false; - } } 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 1bb01f13c..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 @@ -9,11 +9,11 @@ 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'; @@ -32,7 +32,7 @@ export class NameComponent { private readonly destroyRef = inject(DestroyRef); readonly actions = createDispatchMap({ updateProfileSettingsUser: UpdateProfileSettingsUser }); - readonly currentUser = select(ProfileSettingsSelectors.user); + readonly currentUser = select(UserSelectors.getUserNames); readonly previewUser = signal>({}); readonly fb = inject(FormBuilder); @@ -47,8 +47,12 @@ export class NameComponent { constructor() { effect(() => { const user = this.currentUser(); + + if (!user) { + return; + } + this.updateForm(user); - this.updatePreviewUser(); }); this.form.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { @@ -82,6 +86,11 @@ export class NameComponent { discardChanges() { const user = this.currentUser(); + + if (!user) { + return; + } + this.updateForm(user); } 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..e4c416432 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 @@ -11,7 +11,7 @@

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

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

- + {{ getDomain(index) }} 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..6ad7eb794 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 @@ -11,11 +11,12 @@ import { SelectModule } from 'primeng/select'; import { ChangeDetectionStrategy, Component, effect, HostBinding, inject } from '@angular/core'; import { FormArray, FormBuilder, 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'; @Component({ selector: 'osf-social', @@ -30,8 +31,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([]) }); @@ -91,6 +95,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/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/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 978c97d49..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 { 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 9de708f20..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.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.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.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.attributes.social, - }); - }) - ); - } -} 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 bebdb6ba3..b4e386f7f 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/assets/i18n/en.json b/src/assets/i18n/en.json index a64f106be..9e57d92c5 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1411,7 +1411,7 @@ "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", @@ -1432,7 +1432,8 @@ "remove": "Remove", "socialOutput": "Social output", "webAddress": "Web Address", - "addMore": "Add One More" + "addMore": "Add One More", + "successUpdate": "Social successfully updated." }, "employment": { "title": "Position {{index}}", @@ -1443,7 +1444,8 @@ "startDate": "Start Date", "endDate": "End Date", "presentlyEmployed": "Presently employed", - "addPosition": "Add Position" + "addPosition": "Add Position", + "successUpdate": "Employment successfully updated." }, "education": { "title": "Education {{index}}", @@ -1454,7 +1456,8 @@ "startDate": "Start Date", "endDate": "End Date", "ongoing": "Ongoing", - "addMore": "Add One More" + "addMore": "Add One More", + "successUpdate": "Education successfully updated." }, "common": { "discardChanges": "Discard Changes", From 41f60a25464ba644a50a44ef48a00427c01ea8e8 Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 22 Jul 2025 17:50:17 +0300 Subject: [PATCH 06/11] fix(profile-settings): updated education tab --- .../education-form.component.html | 95 +++++++++ .../education-form.component.scss | 0 .../education-form.component.spec.ts | 22 ++ .../education-form.component.ts | 50 +++++ .../education/education.component.html | 110 +--------- .../education/education.component.ts | 195 +++++++++++------- src/assets/i18n/en.json | 4 + 7 files changed, 298 insertions(+), 178 deletions(-) create mode 100644 src/app/features/settings/profile-settings/components/education-form/education-form.component.html create mode 100644 src/app/features/settings/profile-settings/components/education-form/education-form.component.scss create mode 100644 src/app/features/settings/profile-settings/components/education-form/education-form.component.spec.ts create mode 100644 src/app/features/settings/profile-settings/components/education-form/education-form.component.ts 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..06dfd177d --- /dev/null +++ b/src/app/features/settings/profile-settings/components/education-form/education-form.component.html @@ -0,0 +1,95 @@ +
+
+

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

+ + @if (index() !== 0) { +
+ + +
+ } +
+ +
+
+ +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+
+ + + +
+ + @if (!group().controls['ongoing'].value) { +
+ + + +
+ } +
+ +
+ + + +
+
+
+
diff --git a/src/app/features/settings/profile-settings/components/education-form/education-form.component.scss b/src/app/features/settings/profile-settings/components/education-form/education-form.component.scss new file mode 100644 index 000000000..e69de29bb 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..d900d527b --- /dev/null +++ b/src/app/features/settings/profile-settings/components/education-form/education-form.component.ts @@ -0,0 +1,50 @@ +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 { 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, 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; + } + + 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 25d96705e..4c07da1ed 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,101 +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) { + } }
@@ -113,9 +20,12 @@

class="w-6 btn-full-width md:w-auto" [label]="'settings.profileSettings.common.discardChanges' | translate" severity="info" - disabled="true" + (onClick)="discardChanges()" + /> + -
- -

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 838346c90..496b7df1a 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,24 +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 { LoaderService, ToastService } from '@osf/shared/services'; -import { CustomValidators } from '@osf/shared/utils'; +import { CustomConfirmationService, LoaderService, ToastService } from '@osf/shared/services'; +import { CustomValidators, findChangedFields } from '@osf/shared/utils'; -import { MAX_DATE, MIN_DATE } from '../../constants'; import { EducationForm } from '../../models'; +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, @@ -28,47 +34,23 @@ import { EducationForm } from '../../models'; export class EducationComponent { @HostBinding('class') classes = 'flex flex-column gap-5'; - maxDate = MAX_DATE; - minDate = MIN_DATE; - - 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(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, CustomValidators.requiredTrimmed()], - 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 { @@ -76,55 +58,112 @@ export class EducationComponent { } addEducation(): void { - const newEducation = this.fb.group({ - institution: ['', [CustomValidators.requiredTrimmed()]], - 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; + } + + 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; + }); + } - const formattedEducation = educations.map((education) => ({ + 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], + }); + } + + 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))); + } + + 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.loaderService.show(); - - this.actions.updateProfileSettingsEducation({ education: formattedEducation }).subscribe(() => { - this.loaderService.hide(); - this.toastService.showSuccess('settings.profileSettings.education.successUpdate'); - }); + }; } - 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/assets/i18n/en.json b/src/assets/i18n/en.json index 9e57d92c5..22edd7cb1 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", From cc33dc941a9eeeb1e70b05526e20bd9e275f46f7 Mon Sep 17 00:00:00 2001 From: nsemets Date: Tue, 22 Jul 2025 18:57:00 +0300 Subject: [PATCH 07/11] fix(profile-settings): updated models for names --- src/app/core/models/user.mapper.ts | 11 +++++ src/app/core/models/user.models.ts | 8 ++++ src/app/core/services/user.service.ts | 7 +++- src/app/core/store/user/user.actions.ts | 5 --- src/app/core/store/user/user.state.ts | 37 ++++------------- ...tions.model.ts => education-form.model.ts} | 0 ...ment.model.ts => employment-form.model.ts} | 0 .../settings/profile-settings/models/index.ts | 7 ++-- .../models/name-form.model.ts | 9 +++++ .../profile-settings/models/name.model.ts | 40 ------------------- .../models/user-position.model.ts | 8 ---- src/app/shared/enums/index.ts | 1 + .../shared/enums/profile-settings-key.enum.ts | 6 +++ src/app/shared/models/index.ts | 1 + .../models/profile-settings-update.model.ts | 4 ++ 15 files changed, 55 insertions(+), 89 deletions(-) rename src/app/features/settings/profile-settings/models/{educations.model.ts => education-form.model.ts} (100%) rename src/app/features/settings/profile-settings/models/{employment.model.ts => employment-form.model.ts} (100%) create mode 100644 src/app/features/settings/profile-settings/models/name-form.model.ts delete mode 100644 src/app/features/settings/profile-settings/models/name.model.ts delete mode 100644 src/app/features/settings/profile-settings/models/user-position.model.ts create mode 100644 src/app/shared/enums/profile-settings-key.enum.ts create mode 100644 src/app/shared/models/profile-settings-update.model.ts 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 ec224d13f..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'; @@ -34,8 +37,8 @@ export class UserService { .pipe(map((response) => UserMapper.fromUserSettingsGetResponse(response))); } - updateUserProfile(userId: string, key: string, data: unknown): Observable { - const patchedData = { [key]: data }; + updateUserProfile(userId: string, key: string, data: ProfileSettingsUpdate): Observable { + const patchedData = key === ProfileSettingsKey.User ? data : { [key]: data }; return this.jsonApiService .patch(`${environment.apiUrl}/users/${userId}/`, { diff --git a/src/app/core/store/user/user.actions.ts b/src/app/core/store/user/user.actions.ts index 3abbdadc9..2c6f64356 100644 --- a/src/app/core/store/user/user.actions.ts +++ b/src/app/core/store/user/user.actions.ts @@ -23,11 +23,6 @@ export class UpdateUserSettings { ) {} } -export class UpdateUserProfile { - static readonly type = '[User] Update User Profile'; - constructor(public payload: { key: string; data: Partial | Education[] }) {} -} - export class UpdateProfileSettingsEmployment { static readonly type = '[Profile Settings] Update Employment'; diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts index 38370f237..cd43fcb29 100644 --- a/src/app/core/store/user/user.state.ts +++ b/src/app/core/store/user/user.state.ts @@ -5,8 +5,9 @@ import { tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; -import { mapNameToDto } from '@osf/features/settings/profile-settings/models'; +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'; @@ -19,7 +20,6 @@ import { UpdateProfileSettingsEmployment, UpdateProfileSettingsSocialLinks, UpdateProfileSettingsUser, - UpdateUserProfile, UpdateUserSettings, } from './user.actions'; import { USER_STATE_INITIAL, UserStateModel } from './user.model'; @@ -101,29 +101,6 @@ export class UserState { ); } - @Action(UpdateUserProfile) - updateUserProfile(ctx: StateContext, { payload }: UpdateUserProfile) { - const state = ctx.getState(); - const userId = state.currentUser.data?.id; - - if (!userId) { - return; - } - - // const withoutNulls = payload.data.map((item) => removeNullable(item)); - - return this.userService.updateUserProfile(userId, payload.key, payload.data).pipe( - tap((user) => { - ctx.patchState({ - currentUser: { - ...state.currentUser, - data: user, - }, - }); - }) - ); - } - @Action(UpdateProfileSettingsEmployment) updateProfileSettingsEmployment(ctx: StateContext, { payload }: UpdateProfileSettingsEmployment) { const state = ctx.getState(); @@ -135,7 +112,7 @@ export class UserState { const withoutNulls = payload.employment.map((item) => removeNullable(item)); - return this.userService.updateUserProfile(userId, 'employment', withoutNulls).pipe( + return this.userService.updateUserProfile(userId, ProfileSettingsKey.Employment, withoutNulls).pipe( tap((user) => { ctx.patchState({ currentUser: { @@ -158,7 +135,7 @@ export class UserState { const withoutNulls = payload.education.map((item) => removeNullable(item)); - return this.userService.updateUserProfile(userId, 'education', withoutNulls).pipe( + return this.userService.updateUserProfile(userId, ProfileSettingsKey.Education, withoutNulls).pipe( tap((user) => { ctx.patchState({ currentUser: { @@ -179,9 +156,9 @@ export class UserState { return; } - const withoutNulls = mapNameToDto(removeNullable(payload.user)); + const withoutNulls = UserMapper.toNamesRequest(removeNullable(payload.user)); - return this.userService.updateUserProfile(userId, 'user', withoutNulls).pipe( + return this.userService.updateUserProfile(userId, ProfileSettingsKey.User, withoutNulls).pipe( tap((user) => { ctx.patchState({ currentUser: { @@ -211,7 +188,7 @@ export class UserState { }; }); - return this.userService.updateUserProfile(userId, 'social', social).pipe( + return this.userService.updateUserProfile(userId, ProfileSettingsKey.Social, social).pipe( tap((user) => { ctx.patchState({ currentUser: { 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 100% 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 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/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 3b9d4951c..f9d0e115e 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -24,6 +24,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; From 9b3cc69ca653e03a27ef32f7a00238cefef2e85d Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 23 Jul 2025 14:02:41 +0300 Subject: [PATCH 08/11] fix(profile-settings): updated employment and education --- .../education-form.component.html | 15 +- .../education-form.component.ts | 8 +- .../education/education.component.html | 4 +- .../education/education.component.ts | 21 +- .../employment-form.component.html | 98 +++++++++ .../employment-form.component.scss | 0 .../employment-form.component.spec.ts | 22 ++ .../employment-form.component.ts | 55 +++++ .../employment/employment.component.html | 111 +--------- .../employment/employment.component.ts | 203 +++++++++++------- .../profile-settings/components/index.ts | 2 + .../models/employment-form.model.ts | 4 +- src/app/shared/models/user/education.model.ts | 10 +- .../shared/models/user/employment.model.ts | 8 +- .../utils/custom-form-validators.helper.ts | 12 ++ src/assets/i18n/en.json | 7 +- 16 files changed, 362 insertions(+), 218 deletions(-) create mode 100644 src/app/features/settings/profile-settings/components/employment-form/employment-form.component.html create mode 100644 src/app/features/settings/profile-settings/components/employment-form/employment-form.component.scss create mode 100644 src/app/features/settings/profile-settings/components/employment-form/employment-form.component.spec.ts create mode 100644 src/app/features/settings/profile-settings/components/employment-form/employment-form.component.ts 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 index 06dfd177d..db91e94a1 100644 --- 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 @@ -6,12 +6,7 @@

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

@@ -42,7 +37,7 @@

-
+
+ + @if (isDateError) { + + {{ 'settings.profileSettings.endDateError' | translate }} + + }
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 index d900d527b..2f6ea4892 100644 --- 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 @@ -4,6 +4,7 @@ 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'; @@ -18,7 +19,7 @@ import { MAX_DATE, MIN_DATE } from '../../constants'; @Component({ selector: 'osf-education-form', - imports: [ReactiveFormsModule, Button, InputText, DatePicker, Checkbox, TranslatePipe, TextInputComponent], + imports: [ReactiveFormsModule, Button, InputText, DatePicker, Checkbox, Message, TranslatePipe, TextInputComponent], templateUrl: './education-form.component.html', styleUrl: './education-form.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -39,6 +40,11 @@ export class EducationFormComponent implements OnInit { 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( 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 4c07da1ed..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 @@ -18,13 +18,13 @@
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 496b7df1a..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 @@ -119,14 +119,17 @@ export class EducationComponent { } 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], - }); + 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 { @@ -137,6 +140,8 @@ export class EducationComponent { educations .map((education) => this.mapEducationToForm(education)) .forEach((education) => this.educations.push(this.createEducationFormGroup(education))); + + this.cd.markForCheck(); } private mapFormToEducation(education: EducationForm): Education { 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..6ad0b5b17 --- /dev/null +++ b/src/app/features/settings/profile-settings/components/employment-form/employment-form.component.html @@ -0,0 +1,98 @@ +
+
+

+ {{ '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 aec2aa691..1b0d8a2ef 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,103 +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) { + } }
@@ -115,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 07006363f..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,24 +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 { LoaderService, ToastService } from '@osf/shared/services'; -import { CustomValidators } from '@osf/shared/utils'; +import { CustomConfirmationService, LoaderService, ToastService } from '@osf/shared/services'; +import { CustomValidators, findChangedFields } from '@osf/shared/utils'; -import { MAX_DATE, MIN_DATE } from '../../constants'; import { EmploymentForm } from '../../models'; +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, @@ -28,105 +34,140 @@ import { EmploymentForm } from '../../models'; export class EmploymentComponent { @HostBinding('class') classes = 'flex flex-column gap-5'; - maxDate = MAX_DATE; - minDate = MIN_DATE; - 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(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(positionGroup); + this.positions.push(this.createEmploymentFormGroup()); } - removePosition(index: number): void { - this.positions.removeAt(index); + discardChanges(): void { + if (!this.hasFormChanges()) { + return; + } + + this.customConfirmationService.confirmDelete({ + headerKey: 'common.discardChangesDialog.header', + messageKey: 'common.discardChangesDialog.message', + onConfirm: () => { + this.setInitialData(); + this.cd.markForCheck(); + }, + }); } - handleSavePositions(): void { - const employments = this.positions.value as EmploymentForm[]; - - const formattedEmployments = employments.map((employment) => ({ - 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[]; + 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: formattedEmployments }).subscribe(() => { - this.loaderService.hide(); - this.toastService.showSuccess('settings.profileSettings.employment.successUpdate'); + 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(), + }); + } + + 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; }); } - 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 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))); + } + + private mapFormToEmployment(employment: EmploymentForm): Employment { + return { + title: employment.title, + department: employment.department, + institution: employment.institution, + 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 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 918e197e1..446ea66b2 100644 --- a/src/app/features/settings/profile-settings/components/index.ts +++ b/src/app/features/settings/profile-settings/components/index.ts @@ -1,6 +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/models/employment-form.model.ts b/src/app/features/settings/profile-settings/models/employment-form.model.ts index 3b336e38f..f47945d6f 100644 --- a/src/app/features/settings/profile-settings/models/employment-form.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/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/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/assets/i18n/en.json b/src/assets/i18n/en.json index 22edd7cb1..c63298ece 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1441,7 +1441,6 @@ }, "employment": { "title": "Position {{index}}", - "remove": "Remove", "jobTitle": "Job Title", "department": "Department / Institute (Optional)", "institution": "Institution / Employer", @@ -1453,7 +1452,6 @@ }, "education": { "title": "Education {{index}}", - "remove": "Remove", "institution": "Institution", "department": "Department", "degree": "Degree", @@ -1463,10 +1461,7 @@ "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", From e9e933e370cacebe69645f947911896407fb28d0 Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 23 Jul 2025 16:30:10 +0300 Subject: [PATCH 09/11] fix(profile-settings): socials --- .../education-form.component.html | 11 +++- .../employment-form.component.html | 11 +++- .../employment/employment.component.html | 2 +- .../social-form/social-form.component.html | 44 ++++++++++++++ .../social-form/social-form.component.scss | 0 .../social-form/social-form.component.spec.ts | 22 +++++++ .../social-form/social-form.component.ts | 38 ++++++++++++ .../components/social/social.component.html | 58 +++---------------- .../components/social/social.component.ts | 23 +++----- .../profile-settings/constants/data.ts | 14 ++--- 10 files changed, 142 insertions(+), 81 deletions(-) create mode 100644 src/app/features/settings/profile-settings/components/social-form/social-form.component.html create mode 100644 src/app/features/settings/profile-settings/components/social-form/social-form.component.scss create mode 100644 src/app/features/settings/profile-settings/components/social-form/social-form.component.spec.ts create mode 100644 src/app/features/settings/profile-settings/components/social-form/social-form.component.ts 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 index db91e94a1..c26013141 100644 --- 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 @@ -1,12 +1,17 @@
-
+

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

@if (index() !== 0) { -
- +
+
}
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 index 6ad0b5b17..69dc63b08 100644 --- 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 @@ -1,12 +1,17 @@
-
+

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

@if (index() !== 0) { -
- +
+
}
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 1b0d8a2ef..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 @@ -25,7 +25,7 @@ (onClick)="discardChanges()" /> 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 e4c416432..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.ts b/src/app/features/settings/profile-settings/components/social/social.component.ts index 6ad7eb794..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,13 +3,9 @@ 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'; @@ -17,10 +13,11 @@ import { LoaderService, ToastService } from '@osf/shared/services'; import { socials } from '../../constants/data'; import { SOCIAL_KEYS, SocialLinksForm, SocialLinksKeys, UserSocialLink } from '../../models'; +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, @@ -44,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]; @@ -57,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 { @@ -74,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[]; 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', From 430adc779f1d8f8a9d284ad0c7f44b64bd26f568 Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 23 Jul 2025 17:30:20 +0300 Subject: [PATCH 10/11] fix(bug): fixed double scrollbar --- .../token-add-edit-form/token-add-edit-form.component.html | 2 +- .../token-add-edit-form/token-add-edit-form.component.scss | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) 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; -} From fceca14680054d775e46c9f31d5782b87aed14da Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 23 Jul 2025 17:32:08 +0300 Subject: [PATCH 11/11] fix(bug): fixed translate --- .../profile-settings/components/name/name.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e18987da0..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 @@ -5,7 +5,7 @@