Skip to content

Commit 10716b9

Browse files
authored
[ENG-10584][ENG-10585] Allow users to disconnect existing orcid in social tab (#912)
* feat(settings): Allow users to disconnect orcid in social tab * feat(settings): Add dummy connect button when no orcid is associated with user * chore(settings): move authenticated identity to own component * refactor(settings): Implement CR suggestions; Update tests * refactor(settings): Update authenticated identity test * refactor(settings): Update authenticated identity test to use OSFTestingModule
1 parent dd3934b commit 10716b9

11 files changed

Lines changed: 215 additions & 5 deletions

File tree

src/app/features/profile/components/profile-information/profile-information.component.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { toSignal } from '@angular/core/rxjs-interop';
99
import { EducationHistoryComponent } from '@osf/shared/components/education-history/education-history.component';
1010
import { EmploymentHistoryComponent } from '@osf/shared/components/employment-history/employment-history.component';
1111
import { SOCIAL_LINKS } from '@osf/shared/constants/social-links.const';
12+
import { ExternalIdentityStatus } from '@osf/shared/enums/external-identity-status.enum';
1213
import { IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens';
1314
import { UserModel } from '@osf/shared/models/user/user.models';
1415
import { SortByDatePipe } from '@osf/shared/pipes/sort-by-date.pipe';
@@ -45,7 +46,7 @@ export class ProfileInformationComponent {
4546

4647
orcidId = computed(() => {
4748
const orcid = this.currentUser()?.external_identity?.ORCID;
48-
return orcid?.status?.toUpperCase() === 'VERIFIED' ? orcid.id : undefined;
49+
return orcid?.status?.toUpperCase() === ExternalIdentityStatus.VERIFIED ? orcid.id : undefined;
4950
});
5051

5152
toProfileSettings() {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<div class="flex flex-column row-gap-4 border-1 border-round-lg grey-border-color p-3 md:p-4 xl:p-5">
2+
<div class="flex flex-row justify-content-between">
3+
<h2>
4+
{{ 'settings.profileSettings.social.labels.authenticatedIdentity' | translate }}
5+
</h2>
6+
</div>
7+
<div class="flex flex-column row-gap-4 w-full md:flex-row md:align-items-end md:column-gap-3">
8+
<div class="w-full md:w-12">
9+
@if (existingOrcid()) {
10+
<div class="flex flex-row align-items-center gap-2">
11+
<img ngSrc="assets/icons/colored/orcid.svg" width="16" height="16" alt="orcid" />
12+
<a class="font-bold" [href]="orcidUrl()"> {{ orcidUrl() }} </a>
13+
<p-button
14+
icon="fas fa-times"
15+
class="w-6 md:w-auto"
16+
severity="danger"
17+
variant="text"
18+
[pTooltip]="'settings.profileSettings.social.disconnectOrcid' | translate"
19+
[ariaLabel]="'settings.profileSettings.social.disconnectOrcid' | translate"
20+
(onClick)="disconnectOrcid()"
21+
>
22+
</p-button>
23+
</div>
24+
} @else {
25+
<p-button
26+
class="w-6 md:w-auto"
27+
[label]="'settings.profileSettings.social.connectOrcid' | translate"
28+
severity="secondary"
29+
(onClick)="connectOrcid()"
30+
>
31+
</p-button>
32+
<p
33+
class="mt-2 text-sm text-gray-600"
34+
[innerHTML]="'settings.profileSettings.social.orcidDescription' | translate"
35+
></p>
36+
}
37+
</div>
38+
</div>
39+
</div>

src/app/features/settings/profile-settings/components/authenticated-identity/authenticated-identity.component.scss

Whitespace-only changes.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { MockProvider } from 'ng-mocks';
2+
3+
import { signal } from '@angular/core';
4+
import { ComponentFixture, TestBed } from '@angular/core/testing';
5+
6+
import { AccountSettingsSelectors } from '@osf/features/settings/account-settings/store/account-settings.selectors';
7+
import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service';
8+
9+
import { AuthenticatedIdentityComponent } from './authenticated-identity.component';
10+
11+
import { OSFTestingModule } from '@testing/osf.testing.module';
12+
import {
13+
CustomConfirmationServiceMock,
14+
CustomConfirmationServiceMockType,
15+
} from '@testing/providers/custom-confirmation-provider.mock';
16+
import { provideMockStore } from '@testing/providers/store-provider.mock';
17+
18+
describe('AuthenticatedIdentityComponent', () => {
19+
let component: AuthenticatedIdentityComponent;
20+
let fixture: ComponentFixture<AuthenticatedIdentityComponent>;
21+
let customConfirmationServiceMock: CustomConfirmationServiceMockType;
22+
23+
const mockExternalIdentities = signal([
24+
{
25+
id: 'ORCID',
26+
externalId: '0001-0002-0003-0004',
27+
status: 'VERIFIED',
28+
},
29+
]);
30+
31+
beforeEach(async () => {
32+
customConfirmationServiceMock = CustomConfirmationServiceMock.simple();
33+
await TestBed.configureTestingModule({
34+
imports: [AuthenticatedIdentityComponent, OSFTestingModule],
35+
providers: [
36+
MockProvider(CustomConfirmationService, customConfirmationServiceMock),
37+
provideMockStore({
38+
signals: [
39+
{
40+
selector: AccountSettingsSelectors.getExternalIdentities,
41+
value: mockExternalIdentities,
42+
},
43+
],
44+
}),
45+
],
46+
}).compileComponents();
47+
48+
fixture = TestBed.createComponent(AuthenticatedIdentityComponent);
49+
component = fixture.componentInstance;
50+
fixture.detectChanges();
51+
});
52+
53+
it('should create', () => {
54+
expect(component).toBeTruthy();
55+
});
56+
57+
it('should show existing user ORCID when present in external identities', () => {
58+
expect(component.existingOrcid()).toEqual('0001-0002-0003-0004');
59+
expect(component.orcidUrl()).toEqual('https://orcid.org/0001-0002-0003-0004');
60+
component.disconnectOrcid();
61+
expect(customConfirmationServiceMock.confirmDelete).toHaveBeenCalled();
62+
});
63+
64+
it('should show connect button when no existing ORCID is present in external identities', () => {
65+
mockExternalIdentities.set([]);
66+
fixture.detectChanges();
67+
68+
expect(component.existingOrcid()).toBeUndefined();
69+
expect(component.orcidUrl()).toBeNull();
70+
});
71+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { createDispatchMap, select } from '@ngxs/store';
2+
3+
import { TranslatePipe } from '@ngx-translate/core';
4+
5+
import { Button } from 'primeng/button';
6+
import { Tooltip } from 'primeng/tooltip';
7+
8+
import { finalize } from 'rxjs';
9+
10+
import { NgOptimizedImage } from '@angular/common';
11+
import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@angular/core';
12+
13+
import { ExternalIdentityStatus } from '@osf/shared/enums/external-identity-status.enum';
14+
import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service';
15+
import { LoaderService } from '@osf/shared/services/loader.service';
16+
import { ToastService } from '@osf/shared/services/toast.service';
17+
18+
import {
19+
AccountSettingsSelectors,
20+
DeleteExternalIdentity,
21+
GetExternalIdentities,
22+
} from '../../../account-settings/store';
23+
24+
@Component({
25+
selector: 'osf-authenticated-identity',
26+
imports: [NgOptimizedImage, Button, Tooltip, TranslatePipe],
27+
templateUrl: './authenticated-identity.component.html',
28+
styleUrl: './authenticated-identity.component.scss',
29+
changeDetection: ChangeDetectionStrategy.OnPush,
30+
})
31+
export class AuthenticatedIdentityComponent implements OnInit {
32+
private readonly customConfirmationService = inject(CustomConfirmationService);
33+
private readonly toastService = inject(ToastService);
34+
private readonly loaderService = inject(LoaderService);
35+
36+
private readonly ORCID_PROVIDER = 'ORCID';
37+
38+
ngOnInit() {
39+
this.actions.getExternalIdentities();
40+
}
41+
42+
readonly actions = createDispatchMap({
43+
deleteExternalIdentity: DeleteExternalIdentity,
44+
getExternalIdentities: GetExternalIdentities,
45+
});
46+
47+
readonly externalIdentities = select(AccountSettingsSelectors.getExternalIdentities);
48+
49+
readonly orcidUrl = computed(() => {
50+
return this.existingOrcid() ? `https://orcid.org/${this.existingOrcid()}` : null;
51+
});
52+
53+
readonly existingOrcid = computed(
54+
(): string | undefined =>
55+
this.externalIdentities()?.find((i) => i.id === 'ORCID' && i.status === ExternalIdentityStatus.VERIFIED)
56+
?.externalId
57+
);
58+
59+
disconnectOrcid(): void {
60+
this.customConfirmationService.confirmDelete({
61+
headerKey: 'settings.accountSettings.connectedIdentities.deleteDialog.header',
62+
messageParams: { name: this.ORCID_PROVIDER },
63+
messageKey: 'settings.accountSettings.connectedIdentities.deleteDialog.message',
64+
onConfirm: () => {
65+
this.loaderService.show();
66+
this.actions
67+
.deleteExternalIdentity(this.ORCID_PROVIDER)
68+
.pipe(finalize(() => this.loaderService.hide()))
69+
.subscribe(() => this.toastService.showSuccess('settings.accountSettings.connectedIdentities.successDelete'));
70+
},
71+
});
72+
}
73+
74+
connectOrcid(): void {
75+
/* no-op for now*/
76+
}
77+
}

src/app/features/settings/profile-settings/components/social/social.component.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
<osf-authenticated-identity />
2+
13
<form [formGroup]="socialLinksForm">
24
<div formArrayName="links" class="flex flex-column row-gap-4 social">
35
@for (link of links.controls; track index; let index = $index) {

src/app/features/settings/profile-settings/components/social/social.component.spec.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { CustomConfirmationService } from '@osf/shared/services/custom-confirmat
88
import { LoaderService } from '@osf/shared/services/loader.service';
99
import { ToastService } from '@osf/shared/services/toast.service';
1010

11+
import { AuthenticatedIdentityComponent } from '../authenticated-identity/authenticated-identity.component';
1112
import { SocialFormComponent } from '../social-form/social-form.component';
1213

1314
import { SocialComponent } from './social.component';
@@ -24,7 +25,12 @@ describe('SocialComponent', () => {
2425
jest.clearAllMocks();
2526

2627
await TestBed.configureTestingModule({
27-
imports: [SocialComponent, MockComponent(SocialFormComponent), MockPipe(TranslatePipe)],
28+
imports: [
29+
SocialComponent,
30+
MockComponent(SocialFormComponent),
31+
MockComponent(AuthenticatedIdentityComponent),
32+
MockPipe(TranslatePipe),
33+
],
2834
providers: [
2935
provideMockStore({
3036
signals: [{ selector: UserSelectors.getSocialLinks, value: MOCK_USER.social }],

src/app/features/settings/profile-settings/components/social/social.component.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ import { SocialModel } from '@shared/models/user/social.model';
2525
import { SocialLinksForm } from '@shared/models/user/social-links.model';
2626

2727
import { hasSocialLinkChanges, mapSocialLinkToPayload } from '../../helpers';
28+
import { AuthenticatedIdentityComponent } from '../authenticated-identity/authenticated-identity.component';
2829
import { SocialFormComponent } from '../social-form/social-form.component';
2930

3031
@Component({
3132
selector: 'osf-social',
32-
imports: [Button, ReactiveFormsModule, SocialFormComponent, TranslatePipe],
33+
imports: [Button, ReactiveFormsModule, SocialFormComponent, AuthenticatedIdentityComponent, TranslatePipe],
3334
templateUrl: './social.component.html',
3435
styleUrl: './social.component.scss',
3536
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -52,7 +53,9 @@ export class SocialComponent {
5253
readonly socialLinksForm = this.fb.group({ links: this.fb.array<SocialLinksForm>([]) });
5354

5455
constructor() {
55-
effect(() => this.setInitialData());
56+
effect(() => {
57+
this.setInitialData();
58+
});
5659
}
5760

5861
get links(): FormArray<FormGroup> {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export enum ExternalIdentityStatus {
2+
VERIFIED = 'VERIFIED',
3+
LINK = 'LINK',
4+
CREATE = 'CREATE',
5+
}

src/app/shared/models/user/external-identity.model.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { ExternalIdentityStatus } from '@osf/shared/enums/external-identity-status.enum';
2+
13
export interface OrcidInfo {
24
id: string;
3-
status: string;
5+
status: ExternalIdentityStatus;
46
}
57

68
export interface ExternalIdentityModel {

0 commit comments

Comments
 (0)