From f1654a41209cad0a6c6b88eef698e83ad7e62219 Mon Sep 17 00:00:00 2001 From: AS Date: Tue, 6 May 2025 14:27:01 +0300 Subject: [PATCH 1/2] feat(settings): add api integration --- bun.lock | 1 + eslint.config.js | 1 + .../core/constants/ngxs-states.constant.ts | 2 + .../services/json-api/json-api.service.ts | 6 + .../underscore-entites/user/user-us.entity.ts | 11 +- .../services/mappers/users/users.mapper.ts | 26 ++ src/app/core/services/user/user.entity.ts | 9 + src/app/core/store/user/user.selectors.ts | 20 ++ src/app/core/store/user/user.state.ts | 2 + .../settings/profile-settings/data.ts | 14 +- .../education/education.component.html | 169 ++++----- .../education/education.component.ts | 118 +++++-- .../education/educations.entities.ts | 19 + .../employment/employment.component.html | 138 ++++++++ .../employment/employment.component.scss | 0 .../employment/employment.component.ts | 135 ++++++++ .../employment/employment.entities.ts | 19 + .../profile-settings/name/name.component.html | 129 +++++++ .../profile-settings/name/name.component.scss | 36 ++ .../profile-settings/name/name.component.ts | 65 ++++ .../profile-settings/name/name.entities.ts | 39 +++ .../profile-settings.actions.ts | 32 ++ .../profile-settings.api.service.ts | 28 ++ .../profile-settings.component.html | 326 +----------------- .../profile-settings.component.scss | 32 -- .../profile-settings.component.ts | 112 +----- .../profile-settings.entities.ts | 34 ++ .../profile-settings.selectors.ts | 29 ++ .../profile-settings.state.ts | 158 +++++++++ .../social/social.component.html | 70 ++++ .../social/social.component.scss | 0 .../social/social.component.ts | 124 +++++++ .../social/social.entities.ts | 37 ++ src/app/shared/utils/remove-nullable.const.ts | 8 + 34 files changed, 1378 insertions(+), 571 deletions(-) create mode 100644 src/app/features/settings/profile-settings/education/educations.entities.ts create mode 100644 src/app/features/settings/profile-settings/employment/employment.component.html create mode 100644 src/app/features/settings/profile-settings/employment/employment.component.scss create mode 100644 src/app/features/settings/profile-settings/employment/employment.component.ts create mode 100644 src/app/features/settings/profile-settings/employment/employment.entities.ts create mode 100644 src/app/features/settings/profile-settings/name/name.component.html create mode 100644 src/app/features/settings/profile-settings/name/name.component.scss create mode 100644 src/app/features/settings/profile-settings/name/name.component.ts create mode 100644 src/app/features/settings/profile-settings/name/name.entities.ts create mode 100644 src/app/features/settings/profile-settings/profile-settings.actions.ts create mode 100644 src/app/features/settings/profile-settings/profile-settings.api.service.ts create mode 100644 src/app/features/settings/profile-settings/profile-settings.entities.ts create mode 100644 src/app/features/settings/profile-settings/profile-settings.selectors.ts create mode 100644 src/app/features/settings/profile-settings/profile-settings.state.ts create mode 100644 src/app/features/settings/profile-settings/social/social.component.html create mode 100644 src/app/features/settings/profile-settings/social/social.component.scss create mode 100644 src/app/features/settings/profile-settings/social/social.component.ts create mode 100644 src/app/features/settings/profile-settings/social/social.entities.ts create mode 100644 src/app/shared/utils/remove-nullable.const.ts diff --git a/bun.lock b/bun.lock index d6f6d8e37..7f2d54764 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "name": "osf", "dependencies": { + "@angular/animations": "^19.2.0", "@angular/cdk": "^19.2.1", "@angular/cli": "^19.2.0", "@angular/common": "^19.2.0", diff --git a/eslint.config.js b/eslint.config.js index 005199c8e..d0884514b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,7 @@ module.exports = tseslint.config( ], processor: angular.processInlineTemplates, rules: { + "@typescript-eslint/no-unused-vars": "warn", "@angular-eslint/directive-selector": [ "error", { diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index 4c70b0a90..82493bd69 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -4,6 +4,7 @@ import { AddonsState } from '@core/store/settings/addons'; import { UserState } from '@core/store/user'; import { MyProjectsState } from '@core/store/my-projects'; import { SearchState } from '@osf/features/search/store'; +import { ProfileSettingsState } from '@osf/features/settings/profile-settings/profile-settings.state'; export const STATES = [ AuthState, @@ -12,4 +13,5 @@ export const STATES = [ UserState, SearchState, MyProjectsState, + ProfileSettingsState, ]; diff --git a/src/app/core/services/json-api/json-api.service.ts b/src/app/core/services/json-api/json-api.service.ts index c1ce33697..7f157b2d2 100644 --- a/src/app/core/services/json-api/json-api.service.ts +++ b/src/app/core/services/json-api/json-api.service.ts @@ -56,6 +56,12 @@ export class JsonApiService { .pipe(map((response) => response.data)); } + put(url: string, body: unknown): Observable { + return this.http + .put>(url, body, { headers: this.#headers }) + .pipe(map((response) => response.data)); + } + delete(url: string): Observable { return this.http.delete(url, { headers: this.#headers }); } diff --git a/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts b/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts index 73b511b9b..07060b3fb 100644 --- a/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts +++ b/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts @@ -1,3 +1,7 @@ +import { Employment } from '@osf/features/settings/profile-settings/employment/employment.entities'; +import { Education } from '@osf/features/settings/profile-settings/education/educations.entities'; +import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; + export interface UserUS { id: string; type: string; @@ -5,7 +9,12 @@ export interface UserUS { full_name: string; given_name: string; family_name: string; - email: string; + email?: string; + employment: Employment[]; + education: Education[]; + middle_names?: string; + suffix?: string; + social: Social; }; relationships: Record; links: Record; diff --git a/src/app/core/services/mappers/users/users.mapper.ts b/src/app/core/services/mappers/users/users.mapper.ts index 4d5ecc1e6..12b54bbf5 100644 --- a/src/app/core/services/mappers/users/users.mapper.ts +++ b/src/app/core/services/mappers/users/users.mapper.ts @@ -1,12 +1,38 @@ import { User } from '@core/services/user/user.entity'; import { UserUS } from '@core/services/json-api/underscore-entites/user/user-us.entity'; +import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; export function mapUserUStoUser(user: UserUS): User { return { id: user.id, fullName: user.attributes.full_name, givenName: user.attributes.given_name, + middleNames: user.attributes.middle_names, + suffix: user.attributes.suffix, familyName: user.attributes.family_name, email: user.attributes.email, + education: user.attributes.education, + employment: user.attributes.employment, + social: user.attributes.social, + }; +} + +export function mapUserToUserUS(user: Partial | User): Partial { + return { + id: user.id, + type: 'user', + attributes: { + full_name: user.fullName || '', + given_name: user.givenName || '', + family_name: user.familyName || '', + email: user.email, + employment: user.employment || [], + education: user.education || [], + middle_names: user.middleNames, + suffix: user.suffix, + social: {} as Social, + }, + relationships: {}, + links: {}, }; } diff --git a/src/app/core/services/user/user.entity.ts b/src/app/core/services/user/user.entity.ts index cb2410561..32e0a7a96 100644 --- a/src/app/core/services/user/user.entity.ts +++ b/src/app/core/services/user/user.entity.ts @@ -1,7 +1,16 @@ +import { Education } from '@osf/features/settings/profile-settings/education/educations.entities'; +import { Employment } from '@osf/features/settings/profile-settings/employment/employment.entities'; +import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; + export interface User { id: string; fullName: string; givenName: string; familyName: string; email?: string; + middleNames?: string; + suffix?: string; + education: Education[]; + employment: Employment[]; + social: Social; } diff --git a/src/app/core/store/user/user.selectors.ts b/src/app/core/store/user/user.selectors.ts index 783a8df22..586f27935 100644 --- a/src/app/core/store/user/user.selectors.ts +++ b/src/app/core/store/user/user.selectors.ts @@ -2,10 +2,30 @@ import { Selector } from '@ngxs/store'; import { UserStateModel } from '@core/store/user/user.models'; import { User } from '@core/services/user/user.entity'; import { UserState } from '@core/store/user/user.state'; +import { ProfileSettingsStateModel } from '@osf/features/settings/profile-settings/profile-settings.entities'; +import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; export class UserSelectors { @Selector([UserState]) static getCurrentUser(state: UserStateModel): User | null { return state.currentUser; } + + @Selector([UserState]) + static getProfileSettings(state: UserStateModel): ProfileSettingsStateModel { + return { + education: state.currentUser?.education ?? [], + employment: state.currentUser?.employment ?? [], + social: state.currentUser?.social ?? ({} as Social), + user: { + middleNames: state.currentUser?.middleNames ?? '', + suffix: state.currentUser?.suffix ?? '', + id: state.currentUser?.id ?? '', + fullName: state.currentUser?.fullName ?? '', + email: state.currentUser?.email ?? '', + givenName: state.currentUser?.givenName ?? '', + familyName: state.currentUser?.familyName ?? '', + }, + } satisfies ProfileSettingsStateModel; + } } diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts index 96092266c..b1fe72c53 100644 --- a/src/app/core/store/user/user.state.ts +++ b/src/app/core/store/user/user.state.ts @@ -4,6 +4,7 @@ import { UserStateModel } from './user.models'; import { GetCurrentUser, SetCurrentUser } from './user.actions'; import { UserService } from '@core/services/user/user.service'; import { tap } from 'rxjs'; +import { SetupProfileSettings } from '@osf/features/settings/profile-settings/profile-settings.actions'; @State({ name: 'user', @@ -20,6 +21,7 @@ export class UserState { return this.userService.getCurrentUser().pipe( tap((user) => { ctx.dispatch(new SetCurrentUser(user)); + ctx.dispatch(new SetupProfileSettings()); }), ); } diff --git a/src/app/features/settings/profile-settings/data.ts b/src/app/features/settings/profile-settings/data.ts index 88951c1c6..7d2ed0d78 100644 --- a/src/app/features/settings/profile-settings/data.ts +++ b/src/app/features/settings/profile-settings/data.ts @@ -1,62 +1,74 @@ -export const socials = [ +import { SocialLinksEntity } from '@osf/features/settings/profile-settings/social/social.entities'; + +export const socials: SocialLinksEntity[] = [ { id: 0, label: 'ResearcherID', address: 'http://researchers.com/rid/', placeholder: 'x-xxxx-xxxx', + key: 'researcherId', }, { 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: 3, label: 'Twitter', address: '@', placeholder: 'twitterhandle', + key: 'twitter', }, { id: 4, label: 'GitHub', address: 'https://github.com/', placeholder: 'username', + key: 'github', }, { id: 5, label: 'ImpactStory', address: 'https://impactstory.org/u/', placeholder: 'profileID', + key: 'impactStory', }, { id: 6, label: 'Google Scholar', address: 'http://scholar.google.com/citations?user=', placeholder: 'profileID', + key: 'scholar', }, { id: 7, label: 'ResearchGate', address: 'https://researchgate.net/profile/', placeholder: 'profileID', + key: 'researchGate', }, { id: 8, label: 'Baidu Scholar', address: 'http://xueshu.baidu.com/scholarID/', placeholder: 'profileID', + key: 'baiduScholar', }, { id: 9, label: 'SSRN', address: 'http://papers.ssrn.com/sol3/cf_dev/AbsByAuth.cfm?per_id=', placeholder: 'profileID', + key: 'ssrn', }, ]; diff --git a/src/app/features/settings/profile-settings/education/education.component.html b/src/app/features/settings/profile-settings/education/education.component.html index 7bc88f04c..5c9e60ada 100644 --- a/src/app/features/settings/profile-settings/education/education.component.html +++ b/src/app/features/settings/profile-settings/education/education.component.html @@ -1,109 +1,116 @@ +@let haveEducations = educationItems() && educationItems().length > 0;
- @for ( - education of educations.controls; - track education.value; - let index = $index - ) { -
-
-

Education {{ index + 1 }}

- @if (index !== 0) { -
- Remove - -
- } -
- -
-
- - + @if (haveEducations) { + @for ( + education of educations.controls; + track education.value; + let index = $index + ) { +
+
+

Education {{ index + 1 }}

+ @if (index !== 0) { +
+ Remove + +
+ }
-
-
- +
+ -
-
- -
-
-
- Department - +
- - + +
- - +
+ + +
+
+ + +
+
+ +
+ + +
-
+ } }
@@ -125,6 +132,6 @@

Education {{ index + 1 }}

disabled="true" />
- +
diff --git a/src/app/features/settings/profile-settings/education/education.component.ts b/src/app/features/settings/profile-settings/education/education.component.ts index 1b9e8d0f2..a2fd45574 100644 --- a/src/app/features/settings/profile-settings/education/education.component.ts +++ b/src/app/features/settings/profile-settings/education/education.component.ts @@ -1,37 +1,22 @@ import { ChangeDetectionStrategy, Component, + effect, HostBinding, inject, } from '@angular/core'; -import { - FormArray, - FormBuilder, - FormControl, - ReactiveFormsModule, -} from '@angular/forms'; +import { FormArray, FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { Button } from 'primeng/button'; import { InputText } from 'primeng/inputtext'; import { DatePicker } from 'primeng/datepicker'; import { Checkbox } from 'primeng/checkbox'; - -enum EducationFormControls { - Institution = 'institution', - Department = 'department', - Degree = 'degree', - StartDate = 'startDate', - EndDate = 'endDate', - onGoing = 'onGoing', -} - -interface EducationForm { - [EducationFormControls.Institution]: FormControl; - [EducationFormControls.Department]: FormControl; - [EducationFormControls.Degree]: FormControl; - [EducationFormControls.StartDate]: FormControl; - [EducationFormControls.EndDate]: FormControl; - [EducationFormControls.onGoing]: FormControl; -} +import { + Education, + EducationForm, +} from '@osf/features/settings/profile-settings/education/educations.entities'; +import { Store } from '@ngxs/store'; +import { UpdateProfileSettingsEducation } from '@osf/features/settings/profile-settings/profile-settings.actions'; +import { ProfileSettingsSelectors } from '@osf/features/settings/profile-settings/profile-settings.selectors'; @Component({ selector: 'osf-education', @@ -43,11 +28,39 @@ interface EducationForm { export class EducationComponent { @HostBinding('class') classes = 'flex flex-column gap-5'; readonly #fb = inject(FormBuilder); - protected readonly educationFormControls = EducationFormControls; - protected readonly educationForm = this.#fb.group({ educations: this.#fb.array([]), }); + readonly #store = inject(Store); + readonly educationItems = this.#store.selectSignal( + ProfileSettingsSelectors.educations, + ); + + constructor() { + effect(() => { + const educations = this.educationItems(); + if (educations && educations.length > 0) { + this.educations.clear(); + educations.forEach((education) => { + const newEducation = this.#fb.group({ + institution: [education.institution], + department: [education.department], + degree: [education.degree], + startDate: [ + new Date(+education.startYear, education.startMonth - 1), + ], + endDate: education.ongoing + ? '' + : education.endYear && education.endMonth + ? [new Date(+education.endYear, education.endMonth - 1)] + : null, + ongoing: [education.ongoing], + }); + this.educations.push(newEducation); + }); + } + }); + } get educations() { return this.educationForm.get('educations') as FormArray; @@ -59,18 +72,55 @@ export class EducationComponent { addEducation(): void { const newEducation = this.#fb.group({ - [EducationFormControls.Institution]: [''], - [EducationFormControls.Department]: [''], - [EducationFormControls.Degree]: [''], - [EducationFormControls.StartDate]: [null], - [EducationFormControls.EndDate]: [null], - [EducationFormControls.onGoing]: [false], + institution: [''], + department: [''], + degree: [''], + startDate: [null], + endDate: [null], + ongoing: [false], }); this.educations.push(newEducation); } saveEducation(): void { - const educationData = this.educations.value; - console.log('Saved Education Data:', educationData); + const educations = this.educations.value as EducationForm[]; + + const formattedEducation = educations.map((education) => ({ + 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, + ongoing: !education.ongoing, + })) satisfies Education[]; + + this.#store.dispatch( + new UpdateProfileSettingsEducation({ education: formattedEducation }), + ); + } + + private setupDates( + startDate: Date | string, + endDate: Date | null, + ): { + startYear: number; + startMonth: number; + endYear: string | null; + endMonth: number | null; + } { + const start = new Date(startDate); + const end = endDate ? new Date(endDate) : null; + return { + startYear: start.getFullYear(), + startMonth: start.getMonth() + 1, + endYear: end ? end.getFullYear().toString() : null, + endMonth: end ? end.getMonth() + 1 : null, + }; } } diff --git a/src/app/features/settings/profile-settings/education/educations.entities.ts b/src/app/features/settings/profile-settings/education/educations.entities.ts new file mode 100644 index 000000000..13e6813a2 --- /dev/null +++ b/src/app/features/settings/profile-settings/education/educations.entities.ts @@ -0,0 +1,19 @@ +export interface Education { + degree: string; + endYear: string | null; + ongoing: boolean; + endMonth: number | null; + startYear: number; + department: string; + startMonth: number; + institution: string; +} + +export interface EducationForm { + institution: string; + department: string; + degree: string; + startDate: Date; + endDate: Date | null; + ongoing: boolean; +} diff --git a/src/app/features/settings/profile-settings/employment/employment.component.html b/src/app/features/settings/profile-settings/employment/employment.component.html new file mode 100644 index 000000000..7d8708600 --- /dev/null +++ b/src/app/features/settings/profile-settings/employment/employment.component.html @@ -0,0 +1,138 @@ +@let havePositions = employment() && employment().length > 0; + +
+
+ @if (havePositions) { + @for ( + position of positions.controls; + track position.value; + let index = $index + ) { +
+
+

Position {{ index + 1 }}

+ @if (index !== 0) { +
+ Remove + +
+ } +
+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ } + } +
+
+ + + +
+ +
+ +
+
diff --git a/src/app/features/settings/profile-settings/employment/employment.component.scss b/src/app/features/settings/profile-settings/employment/employment.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/settings/profile-settings/employment/employment.component.ts b/src/app/features/settings/profile-settings/employment/employment.component.ts new file mode 100644 index 000000000..81f8df67e --- /dev/null +++ b/src/app/features/settings/profile-settings/employment/employment.component.ts @@ -0,0 +1,135 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + HostBinding, + inject, +} from '@angular/core'; +import { Button } from 'primeng/button'; +import { Checkbox } from 'primeng/checkbox'; +import { DatePicker } from 'primeng/datepicker'; +import { InputText } from 'primeng/inputtext'; +import { + FormArray, + FormBuilder, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { Store } from '@ngxs/store'; +import { ProfileSettingsSelectors } from '@osf/features/settings/profile-settings/profile-settings.selectors'; +import { + Employment, + EmploymentForm, +} from '@osf/features/settings/profile-settings/employment/employment.entities'; +import { UpdateProfileSettingsEmployment } from '@osf/features/settings/profile-settings/profile-settings.actions'; + +@Component({ + selector: 'osf-employment', + imports: [Button, Checkbox, DatePicker, InputText, ReactiveFormsModule], + templateUrl: './employment.component.html', + styleUrl: './employment.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EmploymentComponent { + @HostBinding('class') classes = 'flex flex-column gap-5'; + readonly #store = inject(Store); + readonly employment = this.#store.selectSignal( + ProfileSettingsSelectors.employment, + ); + 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); + }); + } + }); + } + + get positions(): FormArray { + return this.employmentForm.get('positions') as FormArray; + } + + addPosition(): void { + const positionGroup = this.#fb.group({ + title: ['', Validators.required], + department: [''], + institution: ['', Validators.required], + startDate: [null, Validators.required], + endDate: [null, Validators.required], + ongoing: [false], + }); + + this.positions.push(positionGroup); + } + + removePosition(index: number): void { + this.positions.removeAt(index); + } + + handleSavePositions(): void { + const employments = this.positions.value as EmploymentForm[]; + console.log(employments); + + 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[]; + + this.#store.dispatch( + new UpdateProfileSettingsEmployment({ employment: formattedEmployments }), + ); + } + + 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; + + return { + startYear: start.getFullYear(), + startMonth: start.getMonth() + 1, + endYear: end ? end.getFullYear() : null, + endMonth: end ? end.getMonth() + 1 : null, + }; + } +} diff --git a/src/app/features/settings/profile-settings/employment/employment.entities.ts b/src/app/features/settings/profile-settings/employment/employment.entities.ts new file mode 100644 index 000000000..a045ace83 --- /dev/null +++ b/src/app/features/settings/profile-settings/employment/employment.entities.ts @@ -0,0 +1,19 @@ +export interface Employment { + title: string; + startYear: string | number; + startMonth: number; + endYear: number | null; + endMonth: number | null; + ongoing: boolean; + department: string; + institution: string; +} + +export interface EmploymentForm { + title: string; + ongoing: boolean; + department: string; + institution: string; + startDate: Date | string; + endDate: Date | string | null; +} diff --git a/src/app/features/settings/profile-settings/name/name.component.html b/src/app/features/settings/profile-settings/name/name.component.html new file mode 100644 index 000000000..3e8b73285 --- /dev/null +++ b/src/app/features/settings/profile-settings/name/name.component.html @@ -0,0 +1,129 @@ +
+

+ 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. +

+
+
+
+ + + +
+ +
+ +
+
+ +
+
+ + + +
+ +
+ + + +
+
+ +
+
+ + +
+
+ + +
+
+
+
+
+
+

Citation Preview

+
+
+
+

Style:

+

Citation format:

+
+
+

APA

+

Doe, J. T.

+
+
+ +
+
+

Style:

+

Citation format:

+
+
+

MLA

+

Doe, John T.

+
+
+
+
+
+ +
+ +
+ +
+
diff --git a/src/app/features/settings/profile-settings/name/name.component.scss b/src/app/features/settings/profile-settings/name/name.component.scss new file mode 100644 index 000000000..7b2401611 --- /dev/null +++ b/src/app/features/settings/profile-settings/name/name.component.scss @@ -0,0 +1,36 @@ +@use "assets/styles/variables" as var; +@use "assets/styles/mixins" as mix; + +:host { + .name-container { + border: 1px solid var.$grey-2; + border-radius: 8px; + + h3 { + text-transform: none; + } + + label { + font-weight: 300; + color: var.$dark-blue-1; + } + + .name-input { + width: 100%; + border: 1px solid var.$grey-2; + border-radius: 8px; + } + + .styles-container { + column-gap: 8.5rem; + + h3 { + font-weight: 400; + } + + .style-wrapper { + row-gap: 0.85rem; + } + } + } +} diff --git a/src/app/features/settings/profile-settings/name/name.component.ts b/src/app/features/settings/profile-settings/name/name.component.ts new file mode 100644 index 000000000..f43dded86 --- /dev/null +++ b/src/app/features/settings/profile-settings/name/name.component.ts @@ -0,0 +1,65 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + HostBinding, + inject, +} from '@angular/core'; +import { Button } from 'primeng/button'; +import { InputText } from 'primeng/inputtext'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { Store } from '@ngxs/store'; +import { ProfileSettingsSelectors } from '@osf/features/settings/profile-settings/profile-settings.selectors'; +import { NameForm } from '@osf/features/settings/profile-settings/name/name.entities'; +import { UpdateProfileSettingsUser } from '@osf/features/settings/profile-settings/profile-settings.actions'; + +@Component({ + selector: 'osf-name', + imports: [Button, InputText, ReactiveFormsModule], + templateUrl: './name.component.html', + styleUrl: './name.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NameComponent { + @HostBinding('class') classes = 'flex flex-column gap-4 flex-1'; + + readonly #fb = inject(FormBuilder); + readonly form = this.#fb.group({ + fullName: this.#fb.control('', { nonNullable: true }), + 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 = this.#store.selectSignal(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, + }); + }); + } + + saveChanges() { + const { fullName, givenName, middleNames, familyName, suffix } = + this.form.getRawValue(); + this.#store.dispatch( + new UpdateProfileSettingsUser({ + user: { + fullName, + givenName, + middleNames, + familyName, + suffix, + }, + }), + ); + } +} diff --git a/src/app/features/settings/profile-settings/name/name.entities.ts b/src/app/features/settings/profile-settings/name/name.entities.ts new file mode 100644 index 000000000..723c1da40 --- /dev/null +++ b/src/app/features/settings/profile-settings/name/name.entities.ts @@ -0,0 +1,39 @@ +import { FormControl } from '@angular/forms'; +import { User } from '@core/services/user/user.entity'; + +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/profile-settings.actions.ts b/src/app/features/settings/profile-settings/profile-settings.actions.ts new file mode 100644 index 000000000..778bca44d --- /dev/null +++ b/src/app/features/settings/profile-settings/profile-settings.actions.ts @@ -0,0 +1,32 @@ +import { Employment } from '@osf/features/settings/profile-settings/employment/employment.entities'; +import { Education } from '@osf/features/settings/profile-settings/education/educations.entities'; +import { User } from '@core/services/user/user.entity'; +import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; + +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/profile-settings.api.service.ts b/src/app/features/settings/profile-settings/profile-settings.api.service.ts new file mode 100644 index 000000000..1d5f4301e --- /dev/null +++ b/src/app/features/settings/profile-settings/profile-settings.api.service.ts @@ -0,0 +1,28 @@ +import { inject, Injectable } from '@angular/core'; +import { JsonApiService } from '@core/services/json-api/json-api.service'; +import { JsonApiResponse } from '@osf/core/services/json-api/json-api.entity'; +import { UserUS } from '@core/services/json-api/underscore-entites/user/user-us.entity'; +import { + ProfileSettingsStateModel, + ProfileSettingsUpdate, +} from '@osf/features/settings/profile-settings/profile-settings.entities'; + +@Injectable({ + providedIn: 'root', +}) +export class ProfileSettingsApiService { + readonly #baseUrl = 'https://api.staging4.osf.io/v2/'; + readonly #jsonApiService = inject(JsonApiService); + + patchUserSettings( + userId: string, + key: keyof ProfileSettingsStateModel, + data: ProfileSettingsUpdate, + ) { + const patchedData = { [key]: data }; + return this.#jsonApiService.patch>( + `${this.#baseUrl}users/${userId}/`, + { data: { type: 'users', id: userId, attributes: patchedData } }, + ); + } +} diff --git a/src/app/features/settings/profile-settings/profile-settings.component.html b/src/app/features/settings/profile-settings/profile-settings.component.html index 37ecc99dd..fb525b6c2 100644 --- a/src/app/features/settings/profile-settings/profile-settings.component.html +++ b/src/app/features/settings/profile-settings/profile-settings.component.html @@ -31,334 +31,22 @@ > } - -
-

- 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. -

-
-
-
- - -
-
- -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
-
-
-
-
-

Citation Preview

-
-
-
-

Style:

-

Citation format:

-
-
-

APA

-

Doe, J. T.

-
-
- -
-
-

Style:

-

Citation format:

-
-
-

MLA

-

Doe, John T.

-
-
-
-
-
- -
- -
- -
-
+ + - -
- -
- - - -
- -
- -
-
+ + - -
-
- @for ( - position of positions.controls; - track position.value; - let index = $index - ) { -
-
-

Position {{ index + 1 }}

- @if (index !== 0) { -
- Remove - -
- } -
- -
-
- - -
- -
-
- - -
-
- - -
-
- -
-
-
- - -
-
- - -
-
- -
- - -
-
-
-
- } -
-
- - - -
- -
- -
-
+ + - + diff --git a/src/app/features/settings/profile-settings/profile-settings.component.scss b/src/app/features/settings/profile-settings/profile-settings.component.scss index 222634a5c..f1c816620 100644 --- a/src/app/features/settings/profile-settings/profile-settings.component.scss +++ b/src/app/features/settings/profile-settings/profile-settings.component.scss @@ -4,36 +4,4 @@ :host { @include mix.flex-column; flex: 1; - - .name-container { - border: 1px solid var.$grey-2; - border-radius: 8px; - - h3 { - text-transform: none; - } - - label { - font-weight: 300; - color: var.$dark-blue-1; - } - - .name-input { - width: 100%; - border: 1px solid var.$grey-2; - border-radius: 8px; - } - - .styles-container { - column-gap: 8.5rem; - - h3 { - font-weight: 400; - } - - .style-wrapper { - row-gap: 0.85rem; - } - } - } } diff --git a/src/app/features/settings/profile-settings/profile-settings.component.ts b/src/app/features/settings/profile-settings/profile-settings.component.ts index 85dff1adf..f221892cf 100644 --- a/src/app/features/settings/profile-settings/profile-settings.component.ts +++ b/src/app/features/settings/profile-settings/profile-settings.component.ts @@ -1,33 +1,16 @@ -import { - ChangeDetectionStrategy, - Component, - inject, - OnInit, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { SubHeaderComponent } from '@shared/components/sub-header/sub-header.component'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; -import { Button } from 'primeng/button'; import { DropdownModule } from 'primeng/dropdown'; -import { InputText } from 'primeng/inputtext'; -import { UserSocialLink } from '@osf/features/settings/profile-settings/entities/user-social-link.entity'; -import { - FormArray, - FormBuilder, - FormsModule, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; -import { InputGroup } from 'primeng/inputgroup'; -import { InputGroupAddon } from 'primeng/inputgroupaddon'; -import { socials } from '@osf/features/settings/profile-settings/data'; -import { Checkbox } from 'primeng/checkbox'; -import { DatePicker } from 'primeng/datepicker'; -import { UserPosition } from '@osf/features/settings/profile-settings/entities/user-position.entity'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { toSignal } from '@angular/core/rxjs-interop'; import { IS_XSMALL } from '@osf/shared/utils/breakpoints.tokens'; import { TabOption } from '@osf/shared/entities/tab-option.interface'; import { Select } from 'primeng/select'; import { EducationComponent } from '@osf/features/settings/profile-settings/education/education.component'; +import { EmploymentComponent } from '@osf/features/settings/profile-settings/employment/employment.component'; +import { NameComponent } from '@osf/features/settings/profile-settings/name/name.component'; +import { SocialComponent } from '@osf/features/settings/profile-settings/social/social.component'; @Component({ selector: 'osf-profile-settings', @@ -38,36 +21,21 @@ import { EducationComponent } from '@osf/features/settings/profile-settings/educ Tab, TabPanel, TabPanels, - Button, DropdownModule, - InputText, ReactiveFormsModule, - InputGroup, - InputGroupAddon, - Checkbox, - DatePicker, Select, FormsModule, EducationComponent, + EmploymentComponent, + NameComponent, + SocialComponent, ], templateUrl: './profile-settings.component.html', styleUrl: './profile-settings.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ProfileSettingsComponent implements OnInit { +export class ProfileSettingsComponent { protected defaultTabValue = 0; - readonly #fb = inject(FormBuilder); - readonly socials = socials; - - readonly userSocialLinks: UserSocialLink[] = []; - readonly userPositions: UserPosition[] = []; - readonly socialLinksForm = this.#fb.group({ - links: this.#fb.array([]), - }); - - readonly employmentForm = this.#fb.group({ - positions: this.#fb.array([]), - }); protected readonly isMobile = toSignal(inject(IS_XSMALL)); protected readonly tabOptions: TabOption[] = [ { label: 'Name', value: 0 }, @@ -80,66 +48,4 @@ export class ProfileSettingsComponent implements OnInit { onTabChange(index: number): void { this.selectedTab = index; } - - ngOnInit(): void { - if (!this.userSocialLinks.length) { - this.addLink(); - } - - if (!this.userPositions.length) { - this.addPosition(); - } - } - - // Social links methods - get links(): FormArray { - return this.socialLinksForm.get('links') as FormArray; - } - - addLink(): void { - const linkGroup = this.#fb.group({ - socialOutput: [this.socials[0], Validators.required], - webAddress: ['', Validators.required], - }); - - this.links.push(linkGroup); - } - - removeLink(index: number): void { - 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; - } - - // Employment methods - get positions(): FormArray { - return this.employmentForm.get('positions') as FormArray; - } - - addPosition(): void { - const positionGroup = this.#fb.group({ - jobTitle: ['', Validators.required], - department: [''], - institution: ['', Validators.required], - startDate: [null, Validators.required], - endDate: [null, Validators.required], - presentlyEmployed: [false], - }); - - this.positions.push(positionGroup); - } - - removePosition(index: number): void { - this.positions.removeAt(index); - } - - handleSavePositions(): void { - // TODO: Implement save positions - } } diff --git a/src/app/features/settings/profile-settings/profile-settings.entities.ts b/src/app/features/settings/profile-settings/profile-settings.entities.ts new file mode 100644 index 000000000..3999db85e --- /dev/null +++ b/src/app/features/settings/profile-settings/profile-settings.entities.ts @@ -0,0 +1,34 @@ +import { Employment } from '@osf/features/settings/profile-settings/employment/employment.entities'; +import { Education } from '@osf/features/settings/profile-settings/education/educations.entities'; +import { User } from '@core/services/user/user.entity'; +import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; + +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/profile-settings.selectors.ts b/src/app/features/settings/profile-settings/profile-settings.selectors.ts new file mode 100644 index 000000000..f9e0acef7 --- /dev/null +++ b/src/app/features/settings/profile-settings/profile-settings.selectors.ts @@ -0,0 +1,29 @@ +import { Selector } from '@ngxs/store'; +import { ProfileSettingsState } from '@osf/features/settings/profile-settings/profile-settings.state'; +import { Education } from '@osf/features/settings/profile-settings/education/educations.entities'; +import { ProfileSettingsStateModel } from '@osf/features/settings/profile-settings/profile-settings.entities'; +import { Employment } from '@osf/features/settings/profile-settings/employment/employment.entities'; +import { User } from '@core/services/user/user.entity'; +import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; + +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/profile-settings.state.ts b/src/app/features/settings/profile-settings/profile-settings.state.ts new file mode 100644 index 000000000..d8ada74eb --- /dev/null +++ b/src/app/features/settings/profile-settings/profile-settings.state.ts @@ -0,0 +1,158 @@ +import { Action, State, StateContext, Store } from '@ngxs/store'; +import { + PROFILE_SETTINGS_INITIAL_STATE, + PROFILE_SETTINGS_STATE_NAME, + ProfileSettingsStateModel, +} from '@osf/features/settings/profile-settings/profile-settings.entities'; +import { inject, Injectable } from '@angular/core'; +import { + SetupProfileSettings, + UpdateProfileSettingsEducation, + UpdateProfileSettingsEmployment, + UpdateProfileSettingsSocialLinks, + UpdateProfileSettingsUser, +} from '@osf/features/settings/profile-settings/profile-settings.actions'; +import { UserSelectors } from '@core/store/user/user.selectors'; +import { ProfileSettingsApiService } from '@osf/features/settings/profile-settings/profile-settings.api.service'; +import { tap } from 'rxjs'; +import { removeNullable } from '@shared/utils/remove-nullable.const'; +import { mapNameToDto } from '@osf/features/settings/profile-settings/name/name.entities'; +import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; + +@State({ + name: PROFILE_SETTINGS_STATE_NAME, + defaults: PROFILE_SETTINGS_INITIAL_STATE, +}) +@Injectable() +export class ProfileSettingsState { + readonly #store = inject(Store); + 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) => { + return removeNullable(item); + }); + + return this.#profileSettingsService + .patchUserSettings(userId, 'employment', withoutNulls) + .pipe( + tap((response) => { + ctx.patchState({ + ...state, + employment: response.data.attributes.employment, + }); + }), + ); + } + + @Action(UpdateProfileSettingsEducation) + updateProfileSettingsEducation( + ctx: StateContext, + { payload }: UpdateProfileSettingsEducation, + ) { + const state = ctx.getState(); + const userId = state.user.id; + + if (!userId) { + return; + } + + const withoutNulls = payload.education.map((item) => { + return removeNullable(item); + }); + + return this.#profileSettingsService + .patchUserSettings(userId, 'education', withoutNulls) + .pipe( + tap((response) => { + ctx.patchState({ + ...state, + education: response.data.attributes.education, + }); + }), + ); + } + + @Action(UpdateProfileSettingsUser) + updateProfileSettingsUser( + ctx: StateContext, + { payload }: UpdateProfileSettingsUser, + ) { + const state = ctx.getState(); + const userId = state.user.id; + + if (!userId) { + return; + } + + const withoutNulls = mapNameToDto(removeNullable(payload.user)); + + return this.#profileSettingsService + .patchUserSettings(userId, 'user', withoutNulls) + .pipe( + tap((response) => { + ctx.patchState({ + ...state, + user: response.data.attributes, + }); + }), + ); + } + + @Action(UpdateProfileSettingsSocialLinks) + updateProfileSettingsSocialLinks( + ctx: StateContext, + { payload }: UpdateProfileSettingsSocialLinks, + ) { + const state = ctx.getState(); + const userId = state.user.id; + + if (!userId) { + return; + } + + let social = {} as Partial; + + payload.socialLinks.forEach((item) => { + social = { + ...social, + ...item, + }; + }); + + return this.#profileSettingsService + .patchUserSettings(userId, 'social', social) + .pipe( + tap((response) => { + ctx.patchState({ + ...state, + social: response.data.attributes.social, + }); + }), + ); + } +} diff --git a/src/app/features/settings/profile-settings/social/social.component.html b/src/app/features/settings/profile-settings/social/social.component.html new file mode 100644 index 000000000..f29a796f0 --- /dev/null +++ b/src/app/features/settings/profile-settings/social/social.component.html @@ -0,0 +1,70 @@ +
+ +
+ + + +
+ +
+ +
+
diff --git a/src/app/features/settings/profile-settings/social/social.component.scss b/src/app/features/settings/profile-settings/social/social.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/settings/profile-settings/social/social.component.ts b/src/app/features/settings/profile-settings/social/social.component.ts new file mode 100644 index 000000000..d77d11627 --- /dev/null +++ b/src/app/features/settings/profile-settings/social/social.component.ts @@ -0,0 +1,124 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + HostBinding, + inject, +} from '@angular/core'; +import { Button } from 'primeng/button'; +import { DropdownModule } from 'primeng/dropdown'; +import { InputGroup } from 'primeng/inputgroup'; +import { InputGroupAddon } from 'primeng/inputgroupaddon'; +import { InputText } from 'primeng/inputtext'; +import { + FormArray, + FormBuilder, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { socials } from '@osf/features/settings/profile-settings/data'; +import { UserSocialLink } from '@osf/features/settings/profile-settings/entities/user-social-link.entity'; +import { + Social, + SOCIAL_KEYS, + SocialLinksForm, + SocialLinksKeys, +} from '@osf/features/settings/profile-settings/social/social.entities'; +import { Store } from '@ngxs/store'; +import { UpdateProfileSettingsSocialLinks } from '@osf/features/settings/profile-settings/profile-settings.actions'; +import { ProfileSettingsSelectors } from '@osf/features/settings/profile-settings/profile-settings.selectors'; + +@Component({ + selector: 'osf-social', + imports: [ + Button, + DropdownModule, + InputGroup, + InputGroupAddon, + InputText, + ReactiveFormsModule, + ], + templateUrl: './social.component.html', + styleUrl: './social.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SocialComponent { + @HostBinding('class') class = 'flex flex-column gap-5'; + readonly userSocialLinks: UserSocialLink[] = []; + protected readonly socials = socials; + readonly #store = inject(Store); + readonly socialLinks = this.#store.selectSignal( + ProfileSettingsSelectors.socialLinks, + ); + readonly #fb = inject(FormBuilder); + readonly socialLinksForm = this.#fb.group({ + links: this.#fb.array([]), + }); + + constructor() { + effect(() => { + const socialLinks = this.socialLinks(); + + for (const socialLinksKey in socialLinks) { + console.log(socialLinksKey); + const socialLink = socialLinks[socialLinksKey as SocialLinksKeys]; + console.log(socialLink); + + const socialLinkGroup = this.#fb.group({ + socialOutput: [ + this.socials.find((social) => social.key === socialLinksKey), + Validators.required, + ], + webAddress: [socialLink, Validators.required], + }); + + this.links.push(socialLinkGroup); + } + }); + } + + get links(): FormArray { + return this.socialLinksForm.get('links') as FormArray; + } + + addLink(): void { + const linkGroup = this.#fb.group({ + socialOutput: [this.socials[0], Validators.required], + webAddress: ['', Validators.required], + }); + + this.links.push(linkGroup); + } + + removeLink(index: number): void { + 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[]; + + const mappedLinks = links.map((link) => { + const key = link.socialOutput.key as SocialLinksKeys; + + const value = SOCIAL_KEYS.includes(key) + ? [link.webAddress] + : link.webAddress; + + return { + [key]: value, + }; + }) satisfies Partial[]; + + this.#store.dispatch( + new UpdateProfileSettingsSocialLinks({ socialLinks: mappedLinks }), + ); + } +} diff --git a/src/app/features/settings/profile-settings/social/social.entities.ts b/src/app/features/settings/profile-settings/social/social.entities.ts new file mode 100644 index 000000000..ce9cc5739 --- /dev/null +++ b/src/app/features/settings/profile-settings/social/social.entities.ts @@ -0,0 +1,37 @@ +export interface Social { + ssrn: string; + orcid: string; + github: string[]; + scholar: string; + twitter: string[]; + linkedIn: string[]; + impactStory: string; + baiduScholar: string; + researchGate: string; + researcherId: string; + profileWebsites: string[]; + academiaProfileID: string; + academiaInstitution: string; +} + +export type SocialLinksKeys = keyof Social; + +export const SOCIAL_KEYS: SocialLinksKeys[] = [ + 'github', + 'twitter', + 'linkedIn', + 'profileWebsites', +]; + +export interface SocialLinksEntity { + id: number; + label: string; + address: string; + placeholder: string; + key: SocialLinksKeys; +} + +export interface SocialLinksForm { + socialOutput: SocialLinksEntity; + webAddress: string; +} diff --git a/src/app/shared/utils/remove-nullable.const.ts b/src/app/shared/utils/remove-nullable.const.ts new file mode 100644 index 000000000..dde31ab4b --- /dev/null +++ b/src/app/shared/utils/remove-nullable.const.ts @@ -0,0 +1,8 @@ +export function removeNullable(obj: T): Partial { + return Object.fromEntries( + + Object.entries(obj).filter( + ([_, value]) => value !== null && value !== undefined, + ), + ) as Partial; +} From 59a8a317dd62414ba21dd9d2142369c6234f05fb Mon Sep 17 00:00:00 2001 From: AS Date: Tue, 6 May 2025 14:49:20 +0300 Subject: [PATCH 2/2] fix(settings): cleanup settings not final variant --- .../services/mappers/users/users.mapper.ts | 22 ------------------- .../my-profile/my-profile.component.html | 16 +++++++------- .../my-profile/my-profile.component.ts | 2 ++ .../education/education.component.ts | 6 ++--- .../education/educations.entities.ts | 2 +- 5 files changed, 14 insertions(+), 34 deletions(-) diff --git a/src/app/core/services/mappers/users/users.mapper.ts b/src/app/core/services/mappers/users/users.mapper.ts index 2db2673b3..2fab33d11 100644 --- a/src/app/core/services/mappers/users/users.mapper.ts +++ b/src/app/core/services/mappers/users/users.mapper.ts @@ -1,6 +1,5 @@ import { User } from '@core/services/user/user.entity'; import { UserUS } from '@core/services/json-api/underscore-entites/user/user-us.entity'; -import { Social } from '@osf/features/settings/profile-settings/social/social.entities'; export function mapUserUStoUser(user: UserUS): User { return { @@ -19,24 +18,3 @@ export function mapUserUStoUser(user: UserUS): User { social: user.attributes.social, }; } - -export function mapUserToUserUS(user: Partial | User): Partial { - return { - id: user.id, - type: 'user', - attributes: { - date_registered: new Date(user.dateRegistered ?? ''), - full_name: user.fullName || '', - given_name: user.givenName || '', - family_name: user.familyName || '', - email: user.email, - employment: user.employment || [], - education: user.education || [], - middle_names: user.middleNames, - suffix: user.suffix, - social: {} as Social, - }, - relationships: {}, - links: {}, - }; -} diff --git a/src/app/features/my-profile/my-profile.component.html b/src/app/features/my-profile/my-profile.component.html index c9e91bc01..f13d93c7c 100644 --- a/src/app/features/my-profile/my-profile.component.html +++ b/src/app/features/my-profile/my-profile.component.html @@ -99,8 +99,8 @@

Employment

{{ employment.institution }}

- {{ employment.startDate | date: "MMM yyyy" }} - - {{ employment.endDate | date: "MMM yyyy" }} + +

@@ -123,12 +123,12 @@

Education

{{ education.institution }}

- {{ education.startDate | date: "MMM yyyy" }} - - @if (education.ongoing) { - ongoing - } @else { - {{ education.endDate | date: "MMM yyyy" }} - } + + + + + +

diff --git a/src/app/features/my-profile/my-profile.component.ts b/src/app/features/my-profile/my-profile.component.ts index fc0205fd2..9754451fa 100644 --- a/src/app/features/my-profile/my-profile.component.ts +++ b/src/app/features/my-profile/my-profile.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, + computed, effect, inject, OnDestroy, @@ -43,6 +44,7 @@ export class MyProfileComponent implements OnDestroy { readonly #store = inject(Store); readonly #router = inject(Router); readonly currentUser = this.#store.selectSignal(UserSelectors.getCurrentUser); + readonly isMobile = toSignal(inject(IS_XSMALL)); protected searchValue = signal(''); diff --git a/src/app/features/settings/profile-settings/education/education.component.ts b/src/app/features/settings/profile-settings/education/education.component.ts index a2fd45574..0ea8e8d63 100644 --- a/src/app/features/settings/profile-settings/education/education.component.ts +++ b/src/app/features/settings/profile-settings/education/education.component.ts @@ -97,7 +97,7 @@ export class EducationComponent { endMonth: education.ongoing ? null : this.setupDates('', education.endDate).endMonth, - ongoing: !education.ongoing, + ongoing: education.ongoing, })) satisfies Education[]; this.#store.dispatch( @@ -111,7 +111,7 @@ export class EducationComponent { ): { startYear: number; startMonth: number; - endYear: string | null; + endYear: number | null; endMonth: number | null; } { const start = new Date(startDate); @@ -119,7 +119,7 @@ export class EducationComponent { return { startYear: start.getFullYear(), startMonth: start.getMonth() + 1, - endYear: end ? end.getFullYear().toString() : null, + endYear: end ? end.getFullYear() : null, endMonth: end ? end.getMonth() + 1 : null, }; } diff --git a/src/app/features/settings/profile-settings/education/educations.entities.ts b/src/app/features/settings/profile-settings/education/educations.entities.ts index 13e6813a2..d2eceb848 100644 --- a/src/app/features/settings/profile-settings/education/educations.entities.ts +++ b/src/app/features/settings/profile-settings/education/educations.entities.ts @@ -1,6 +1,6 @@ export interface Education { degree: string; - endYear: string | null; + endYear: number | null; ongoing: boolean; endMonth: number | null; startYear: number;