From b2aa2f4f0199f7a9b8032435aa1d760fdc9f6494 Mon Sep 17 00:00:00 2001 From: futa-ikeda Date: Tue, 14 Oct 2025 15:22:11 -0400 Subject: [PATCH 1/3] feat(addons): Allow fetching redirect addons --- .../project-addons.component.ts | 14 +++++++++-- .../addons-category-options.const.ts | 4 +++ src/app/shared/enums/addon-type.enum.ts | 1 + src/app/shared/enums/addons-category.enum.ts | 1 + src/app/shared/mappers/addon.mapper.ts | 1 + .../models/addons/addon-json-api.models.ts | 1 + src/app/shared/models/addons/addon.model.ts | 1 + .../shared/stores/addons/addons.actions.ts | 4 +++ src/app/shared/stores/addons/addons.models.ts | 6 +++++ .../shared/stores/addons/addons.selectors.ts | 10 ++++++++ src/app/shared/stores/addons/addons.state.ts | 25 +++++++++++++++++++ src/assets/i18n/en.json | 3 ++- 12 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/app/features/project/project-addons/project-addons.component.ts b/src/app/features/project/project-addons/project-addons.component.ts index 2adad4aac..78904e4b0 100644 --- a/src/app/features/project/project-addons/project-addons.component.ts +++ b/src/app/features/project/project-addons/project-addons.component.ts @@ -42,6 +42,7 @@ import { GetConfiguredLinkAddons, GetConfiguredStorageAddons, GetLinkAddons, + GetRedirectAddons, GetStorageAddons, } from '@shared/stores/addons'; import { CurrentResourceSelectors } from '@shared/stores/current-resource'; @@ -86,6 +87,7 @@ export class ProjectAddonsComponent implements OnInit { storageAddons = select(AddonsSelectors.getStorageAddons); citationAddons = select(AddonsSelectors.getCitationAddons); linkAddons = select(AddonsSelectors.getLinkAddons); + redirectAddons = select(AddonsSelectors.getRedirectAddons); configuredStorageAddons = select(AddonsSelectors.getConfiguredStorageAddons); configuredCitationAddons = select(AddonsSelectors.getConfiguredCitationAddons); configuredLinkAddons = select(AddonsSelectors.getConfiguredLinkAddons); @@ -95,6 +97,8 @@ export class ProjectAddonsComponent implements OnInit { isResourceReferenceLoading = select(AddonsSelectors.getAddonsResourceReferenceLoading); isStorageAddonsLoading = select(AddonsSelectors.getStorageAddonsLoading); isCitationAddonsLoading = select(AddonsSelectors.getCitationAddonsLoading); + isLinkAddonsLoading = select(AddonsSelectors.getLinkAddonsLoading); + isRedirectAddonsLoading = select(AddonsSelectors.getRedirectAddonsLoading); isConfiguredStorageAddonsLoading = select(AddonsSelectors.getConfiguredStorageAddonsLoading); isConfiguredCitationAddonsLoading = select(AddonsSelectors.getConfiguredCitationAddonsLoading); isConfiguredLinkAddonsLoading = select(AddonsSelectors.getConfiguredLinkAddonsLoading); @@ -103,6 +107,7 @@ export class ProjectAddonsComponent implements OnInit { this.isStorageAddonsLoading() || this.isCitationAddonsLoading() || this.isLinkAddonsLoading() || + this.isRedirectAddonsLoading() || this.isUserReferenceLoading() || this.isCurrentUserLoading() ); @@ -130,8 +135,6 @@ export class ProjectAddonsComponent implements OnInit { return categoryLoading || this.isResourceReferenceLoading() || this.isCurrentUserLoading(); }); - isLinkAddonsLoading = select(AddonsSelectors.getLinkAddonsLoading); - currentAddonsLoading = computed(() => { switch (this.selectedCategory()) { case AddonCategory.EXTERNAL_STORAGE_SERVICES: @@ -140,6 +143,8 @@ export class ProjectAddonsComponent implements OnInit { return this.isCitationAddonsLoading(); case AddonCategory.EXTERNAL_LINK_SERVICES: return this.isLinkAddonsLoading(); + case AddonCategory.EXTERNAL_REDIRECT_SERVICES: + return this.isRedirectAddonsLoading(); default: return this.isStorageAddonsLoading(); } @@ -153,6 +158,7 @@ export class ProjectAddonsComponent implements OnInit { getStorageAddons: GetStorageAddons, getCitationAddons: GetCitationAddons, getLinkAddons: GetLinkAddons, + getRedirectAddons: GetRedirectAddons, getConfiguredStorageAddons: GetConfiguredStorageAddons, getConfiguredCitationAddons: GetConfiguredCitationAddons, getConfiguredLinkAddons: GetConfiguredLinkAddons, @@ -216,6 +222,8 @@ export class ProjectAddonsComponent implements OnInit { return this.actions.getCitationAddons; case AddonCategory.EXTERNAL_LINK_SERVICES: return this.actions.getLinkAddons; + case AddonCategory.EXTERNAL_REDIRECT_SERVICES: + return this.actions.getRedirectAddons; default: return this.actions.getStorageAddons; } @@ -229,6 +237,8 @@ export class ProjectAddonsComponent implements OnInit { return this.citationAddons(); case AddonCategory.EXTERNAL_LINK_SERVICES: return this.linkAddons(); + case AddonCategory.EXTERNAL_REDIRECT_SERVICES: + return this.redirectAddons(); default: return this.storageAddons(); } diff --git a/src/app/shared/constants/addons-category-options.const.ts b/src/app/shared/constants/addons-category-options.const.ts index 7d36a88f3..ee7d12630 100644 --- a/src/app/shared/constants/addons-category-options.const.ts +++ b/src/app/shared/constants/addons-category-options.const.ts @@ -14,4 +14,8 @@ export const ADDON_CATEGORY_OPTIONS: SelectOption[] = [ label: 'settings.addons.categories.linkedServices', value: AddonCategory.EXTERNAL_LINK_SERVICES, }, + { + label: 'settings.addons.categories.otherServices', + value: AddonCategory.EXTERNAL_REDIRECT_SERVICES, + }, ]; diff --git a/src/app/shared/enums/addon-type.enum.ts b/src/app/shared/enums/addon-type.enum.ts index 8b16ce88e..041fd556d 100644 --- a/src/app/shared/enums/addon-type.enum.ts +++ b/src/app/shared/enums/addon-type.enum.ts @@ -2,6 +2,7 @@ export enum AddonType { STORAGE = 'storage', CITATION = 'citation', LINK = 'link', + REDIRECT = 'redirect', // Redirect addons will not have authorized accounts or configured addons } export enum AuthorizedAccountType { diff --git a/src/app/shared/enums/addons-category.enum.ts b/src/app/shared/enums/addons-category.enum.ts index c08bb9358..7965c3eb3 100644 --- a/src/app/shared/enums/addons-category.enum.ts +++ b/src/app/shared/enums/addons-category.enum.ts @@ -2,4 +2,5 @@ export enum AddonCategory { EXTERNAL_STORAGE_SERVICES = 'external-storage-services', EXTERNAL_CITATION_SERVICES = 'external-citation-services', EXTERNAL_LINK_SERVICES = 'external-link-services', + EXTERNAL_REDIRECT_SERVICES = 'external-redirect-services', } diff --git a/src/app/shared/mappers/addon.mapper.ts b/src/app/shared/mappers/addon.mapper.ts index fa3c4d73d..72cfd9c1e 100644 --- a/src/app/shared/mappers/addon.mapper.ts +++ b/src/app/shared/mappers/addon.mapper.ts @@ -26,6 +26,7 @@ export class AddonMapper { credentialsFormat: response.attributes.credentials_format, providerName: response.attributes.display_name, iconUrl: response.attributes.icon_url, + redirectUrl: response.attributes.redirect_url, }; } diff --git a/src/app/shared/models/addons/addon-json-api.models.ts b/src/app/shared/models/addons/addon-json-api.models.ts index 68ed09b73..c66e0879e 100644 --- a/src/app/shared/models/addons/addon-json-api.models.ts +++ b/src/app/shared/models/addons/addon-json-api.models.ts @@ -10,6 +10,7 @@ export interface AddonGetResponseJsonApi { credentials_format: string; wb_key: string; icon_url: string; + redirect_url?: string; [key: string]: unknown; }; relationships: { diff --git a/src/app/shared/models/addons/addon.model.ts b/src/app/shared/models/addons/addon.model.ts index 90d17e5df..bb287bd61 100644 --- a/src/app/shared/models/addons/addon.model.ts +++ b/src/app/shared/models/addons/addon.model.ts @@ -13,4 +13,5 @@ export interface AddonModel { credentialsAvailable?: boolean; supportedResourceTypes?: string[]; wbKey?: string; + redirectUrl?: string; } diff --git a/src/app/shared/stores/addons/addons.actions.ts b/src/app/shared/stores/addons/addons.actions.ts index 24568c0d5..eead6462b 100644 --- a/src/app/shared/stores/addons/addons.actions.ts +++ b/src/app/shared/stores/addons/addons.actions.ts @@ -16,6 +16,10 @@ export class GetLinkAddons { static readonly type = '[Addons] Get Link Addons'; } +export class GetRedirectAddons { + static readonly type = '[Addons] Get Other Addons'; +} + export class GetAuthorizedStorageAddons { static readonly type = '[Addons] Get Authorized Storage Addons'; diff --git a/src/app/shared/stores/addons/addons.models.ts b/src/app/shared/stores/addons/addons.models.ts index 74e128b69..d2e298d32 100644 --- a/src/app/shared/stores/addons/addons.models.ts +++ b/src/app/shared/stores/addons/addons.models.ts @@ -13,6 +13,7 @@ export interface AddonsStateModel { storageAddons: AsyncStateModel; citationAddons: AsyncStateModel; linkAddons: AsyncStateModel; + redirectAddons: AsyncStateModel; authorizedStorageAddons: AsyncStateModel; authorizedCitationAddons: AsyncStateModel; authorizedLinkAddons: AsyncStateModel; @@ -44,6 +45,11 @@ export const ADDONS_DEFAULTS: AddonsStateModel = { isLoading: false, error: null, }, + redirectAddons: { + data: [], + isLoading: false, + error: null, + }, authorizedStorageAddons: { data: [], isLoading: false, diff --git a/src/app/shared/stores/addons/addons.selectors.ts b/src/app/shared/stores/addons/addons.selectors.ts index 153c9ee06..182d0e2f4 100644 --- a/src/app/shared/stores/addons/addons.selectors.ts +++ b/src/app/shared/stores/addons/addons.selectors.ts @@ -51,6 +51,16 @@ export class AddonsSelectors { return state.linkAddons.isLoading; } + @Selector([AddonsState]) + static getRedirectAddons(state: AddonsStateModel): AddonModel[] { + return state.redirectAddons.data; + } + + @Selector([AddonsState]) + static getRedirectAddonsLoading(state: AddonsStateModel): boolean { + return state.redirectAddons.isLoading; + } + @Selector([AddonsState]) static getAuthorizedStorageAddons(state: AddonsStateModel): AuthorizedAccountModel[] { return state.authorizedStorageAddons.data; diff --git a/src/app/shared/stores/addons/addons.state.ts b/src/app/shared/stores/addons/addons.state.ts index 1b3afb253..2afba2263 100644 --- a/src/app/shared/stores/addons/addons.state.ts +++ b/src/app/shared/stores/addons/addons.state.ts @@ -30,6 +30,7 @@ import { GetConfiguredLinkAddons, GetConfiguredStorageAddons, GetLinkAddons, + GetRedirectAddons, GetStorageAddons, UpdateAuthorizedAddon, UpdateConfiguredAddon, @@ -116,6 +117,30 @@ export class AddonsState { ); } + @Action(GetRedirectAddons) + getRedirectAddons(ctx: StateContext) { + const state = ctx.getState(); + ctx.patchState({ + redirectAddons: { + ...state.redirectAddons, + isLoading: true, + }, + }); + + return this.addonsService.getAddons(AddonType.REDIRECT).pipe( + tap((addons) => { + ctx.patchState({ + redirectAddons: { + data: addons, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'redirectAddons', error)) + ); + } + @Action(GetAuthorizedStorageAddons) getAuthorizedStorageAddons(ctx: StateContext, action: GetAuthorizedStorageAddons) { const state = ctx.getState(); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 58c0b767d..aa571b710 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1681,7 +1681,8 @@ "categories": { "additionalService": "Additional Storage", "citationManager": "Citation Manager", - "linkedServices": "Linked Services" + "linkedServices": "Linked Services", + "otherServices": "Other Services" }, "toast": { "updateSuccess": "Successfully updated {{addonName}} add-on configuration", From 6dc1ce5291f482547aaf92b86870d0cff6707259 Mon Sep 17 00:00:00 2001 From: futa-ikeda Date: Mon, 20 Oct 2025 18:06:21 -0400 Subject: [PATCH 2/3] feat(addons): Update terms page for redirect addons --- .../connect-configured-addon.component.html | 36 +++++++---- .../connect-configured-addon.component.ts | 27 ++++++++ .../addon-terms/addon-terms.component.html | 61 +++++++++++-------- .../addon-terms/addon-terms.component.ts | 10 ++- src/app/shared/helpers/addon-type.helper.ts | 6 ++ src/assets/i18n/en.json | 6 ++ 6 files changed, 109 insertions(+), 37 deletions(-) diff --git a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.html b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.html index b3a940436..b775a6474 100644 --- a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.html +++ b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.html @@ -11,7 +11,7 @@

- +
@@ -33,17 +33,29 @@

[routerLink]="[baseUrl() + '/addons']" data-test-addon-cancel-button > - + @if (addonTypeString() === AddonType.REDIRECT) { + + } @else { + + }

diff --git a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts index 1d9c77d8d..5375f5f98 100644 --- a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts +++ b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts @@ -144,6 +144,17 @@ export class ConnectConfiguredAddonComponent { addonTypeString = computed(() => getAddonTypeString(this.addon())); + redirectUrl = computed(() => { + const addon = this.addon(); + if (!addon || !addon.redirectUrl) { + return null; + } + const openURL = new URL(addon.redirectUrl); + openURL.searchParams.set('nodeIri', this.resourceUri()); + openURL.searchParams.set('userIri', this.addonsUserReference()[0]?.attributes.user_uri); + return openURL.toString(); + }); + readonly baseUrl = computed(() => { const currentUrl = this.router.url; return currentUrl.split('/addons')[0]; @@ -253,6 +264,22 @@ export class ConnectConfiguredAddonComponent { }); } + goToService() { + if (!this.redirectUrl()) return; + + const newWindow = window.open( + this.redirectUrl()!.toString(), + '_blank', + 'popup,width=600,height=600,scrollbars=yes,resizable=yes' + ); + if (newWindow) { + this.router.navigate([`${this.baseUrl()}/addons`]); + newWindow.focus(); + } else { + this.toastService.showError('addons.redirect.pop-up-error'); + } + } + private getDataForAccountCheck() { const addonType = this.addonTypeString(); const referenceId = this.userReferenceId(); diff --git a/src/app/shared/components/addons/addon-terms/addon-terms.component.html b/src/app/shared/components/addons/addon-terms/addon-terms.component.html index fb204c88f..80a0cfb42 100644 --- a/src/app/shared/components/addons/addon-terms/addon-terms.component.html +++ b/src/app/shared/components/addons/addon-terms/addon-terms.component.html @@ -1,24 +1,37 @@ - - - - - {{ 'settings.addons.connectAddon.table.function' | translate }} - - - {{ 'settings.addons.connectAddon.table.status' | translate }} - - - - - - {{ term.function }} - {{ term.status }} - - - +@if (isRedirectService()) { +

+ {{ 'settings.addons.connectAddon.redirectAddons.terms' | translate }} +

+ +} @else { + + + + + {{ 'settings.addons.connectAddon.table.function' | translate }} + + + {{ 'settings.addons.connectAddon.table.status' | translate }} + + + + + + {{ term.function }} + {{ term.status }} + + + +} diff --git a/src/app/shared/components/addons/addon-terms/addon-terms.component.ts b/src/app/shared/components/addons/addon-terms/addon-terms.component.ts index a97b0a0cf..5ed4a483a 100644 --- a/src/app/shared/components/addons/addon-terms/addon-terms.component.ts +++ b/src/app/shared/components/addons/addon-terms/addon-terms.component.ts @@ -5,7 +5,7 @@ import { TableModule } from 'primeng/table'; import { NgClass } from '@angular/common'; import { Component, computed, input } from '@angular/core'; -import { isCitationAddon } from '@osf/shared/helpers'; +import { isCitationAddon, isRedirectAddon } from '@osf/shared/helpers'; import { ADDON_TERMS as addonTerms } from '@shared/constants'; import { AddonModel, AddonTerm, AuthorizedAccountModel } from '@shared/models'; @@ -17,6 +17,11 @@ import { AddonModel, AddonTerm, AuthorizedAccountModel } from '@shared/models'; }) export class AddonTermsComponent { addon = input(null); + redirectUrl = input(null); + + isRedirectService = computed(() => { + return isRedirectAddon(this.addon()); + }); terms = computed(() => { const addon = this.addon(); if (!addon) { @@ -29,6 +34,9 @@ export class AddonTermsComponent { const supportedFeatures = addon.supportedFeatures || []; const provider = addon.providerName; const isCitationService = isCitationAddon(addon); + if (isRedirectAddon(addon)) { + return []; + } const relevantTerms = isCitationService ? addonTerms.filter((term) => term.citation) : addonTerms; diff --git a/src/app/shared/helpers/addon-type.helper.ts b/src/app/shared/helpers/addon-type.helper.ts index beea10396..858615808 100644 --- a/src/app/shared/helpers/addon-type.helper.ts +++ b/src/app/shared/helpers/addon-type.helper.ts @@ -31,6 +31,12 @@ export function isLinkAddon(addon: AddonModel | AuthorizedAccountModel | Configu ); } +export function isRedirectAddon(addon: AddonModel | AuthorizedAccountModel | ConfiguredAddonModel | null): boolean { + if (!addon) return false; + + return addon.type === AddonCategory.EXTERNAL_REDIRECT_SERVICES; +} + export function getAddonTypeString(addon: AddonModel | AuthorizedAccountModel | ConfiguredAddonModel | null): string { if (!addon) return ''; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index aa571b710..f0bf6f4f9 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1722,6 +1722,12 @@ "function": "Function", "status": "Status" }, + "redirectAddons": { + "terms": "Clicking the button below will redirect you outside of OSF. You will need to follow that service's permissions to continue.", + "tip": "Tip: if the page does not open, please disable your browser's pop-up blocker and try again. Or click on this link to go to {{serviceName}}.", + "popupError": "If you are having trouble with the pop-up window, please try clicking the link above to connect your account:", + "goToService": "Go to {{serviceName}}" + }, "confirmAccount": "Confirm account", "connectAccount": "Connect following account: {{accountName}}", "configure": "Configure", From 04ec7ddbbc66defa274fa0955b44f9a85387486c Mon Sep 17 00:00:00 2001 From: futa-ikeda Date: Tue, 21 Oct 2025 14:20:27 -0400 Subject: [PATCH 3/3] test(addons): add tests for redirect addons terms --- .../connect-configured-addon.component.ts | 2 +- .../addon-terms/addon-terms.component.spec.ts | 25 ++++++++++++++++++- .../addon-terms/addon-terms.component.ts | 4 +-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts index 5375f5f98..6329f378a 100644 --- a/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts +++ b/src/app/features/project/project-addons/components/connect-configured-addon/connect-configured-addon.component.ts @@ -276,7 +276,7 @@ export class ConnectConfiguredAddonComponent { this.router.navigate([`${this.baseUrl()}/addons`]); newWindow.focus(); } else { - this.toastService.showError('addons.redirect.pop-up-error'); + this.toastService.showError('addons.redirect.popUpError'); } } diff --git a/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts b/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts index 8a8278304..018e08a7e 100644 --- a/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts +++ b/src/app/shared/components/addons/addon-terms/addon-terms.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { isCitationAddon } from '@osf/shared/helpers'; +import { isCitationAddon, isRedirectAddon } from '@osf/shared/helpers'; import { AddonTermsComponent } from '@shared/components/addons'; import { ADDON_TERMS } from '@shared/constants'; import { AddonModel, AddonTerm } from '@shared/models'; @@ -10,12 +10,14 @@ import { OSFTestingModule } from '@testing/osf.testing.module'; jest.mock('@shared/helpers', () => ({ isCitationAddon: jest.fn(), + isRedirectAddon: jest.fn(), })); describe('AddonTermsComponent', () => { let component: AddonTermsComponent; let fixture: ComponentFixture; const mockIsCitationAddon = isCitationAddon as jest.MockedFunction; + const mockIsRedirectAddon = isRedirectAddon as jest.MockedFunction; const mockAddon: AddonModel = MOCK_ADDON; beforeEach(async () => { @@ -27,6 +29,7 @@ describe('AddonTermsComponent', () => { component = fixture.componentInstance; mockIsCitationAddon.mockReturnValue(false); + mockIsRedirectAddon.mockReturnValue(false); }); it('should create', () => { @@ -210,4 +213,24 @@ describe('AddonTermsComponent', () => { expect(hasInfoTerm || hasWarningTerm || hasDangerTerm).toBe(true); }); + + it('should handle redirect terms correctly', () => { + const redirectAddon: AddonModel = { + ...mockAddon, + type: 'redirect', + }; + + mockIsRedirectAddon.mockReturnValue(true); + fixture.componentRef.setInput('addon', redirectAddon); + fixture.detectChanges(); + + const terms = component.terms(); + expect(terms).toEqual([]); + + const termsElement: HTMLElement = fixture.nativeElement; + expect(termsElement.querySelectorAll('tr').length).toBe(0); + expect(termsElement.querySelectorAll('p').length).toBe(1); + expect(termsElement.querySelectorAll('em').length).toBe(1); + expect(termsElement.textContent).toContain('settings.addons.connectAddon.redirectAddons.terms'); + }); }); diff --git a/src/app/shared/components/addons/addon-terms/addon-terms.component.ts b/src/app/shared/components/addons/addon-terms/addon-terms.component.ts index 5ed4a483a..6f4e60b87 100644 --- a/src/app/shared/components/addons/addon-terms/addon-terms.component.ts +++ b/src/app/shared/components/addons/addon-terms/addon-terms.component.ts @@ -19,9 +19,7 @@ export class AddonTermsComponent { addon = input(null); redirectUrl = input(null); - isRedirectService = computed(() => { - return isRedirectAddon(this.addon()); - }); + isRedirectService = computed(() => isRedirectAddon(this.addon())); terms = computed(() => { const addon = this.addon(); if (!addon) {