From 0cc74308d9947c65987a19ea1cda8bf5f3a5214b Mon Sep 17 00:00:00 2001 From: Kyrylo Petrov Date: Wed, 28 May 2025 20:04:31 +0300 Subject: [PATCH 01/18] chore(157): project-addons-ui --- src/app/app.routes.ts | 5 + src/app/core/constants/nav-items.constant.ts | 4 + .../project/addons/addons.component.html | 82 +++++++++++ .../project/addons/addons.component.scss | 11 ++ .../project/addons/addons.component.spec.ts | 22 +++ .../project/addons/addons.component.ts | 134 ++++++++++++++++++ .../settings/addons/addons.component.spec.ts | 2 +- .../settings/addons/addons.component.ts | 2 +- .../settings/addons/components/index.ts | 2 - .../addon-card-list.component.html | 0 .../addon-card-list.component.scss | 0 .../addon-card-list.component.spec.ts | 2 +- .../addon-card-list.component.ts | 4 +- .../addon-card/addon-card.component.html | 0 .../addon-card/addon-card.component.scss | 2 +- .../addon-card/addon-card.component.spec.ts | 4 +- .../addon-card/addon-card.component.ts | 6 +- src/app/shared/components/addons/index.ts | 2 + src/assets/i18n/en.json | 3 +- 19 files changed, 273 insertions(+), 14 deletions(-) create mode 100644 src/app/features/project/addons/addons.component.html create mode 100644 src/app/features/project/addons/addons.component.scss create mode 100644 src/app/features/project/addons/addons.component.spec.ts create mode 100644 src/app/features/project/addons/addons.component.ts delete mode 100644 src/app/features/settings/addons/components/index.ts rename src/app/{features/settings/addons/components => shared/components/addons}/addon-card-list/addon-card-list.component.html (100%) rename src/app/{features/settings/addons/components => shared/components/addons}/addon-card-list/addon-card-list.component.scss (100%) rename src/app/{features/settings/addons/components => shared/components/addons}/addon-card-list/addon-card-list.component.spec.ts (88%) rename src/app/{features/settings/addons/components => shared/components/addons}/addon-card-list/addon-card-list.component.ts (74%) rename src/app/{features/settings/addons/components => shared/components/addons}/addon-card/addon-card.component.html (100%) rename src/app/{features/settings/addons/components => shared/components/addons}/addon-card/addon-card.component.scss (90%) rename src/app/{features/settings/addons/components => shared/components/addons}/addon-card/addon-card.component.spec.ts (89%) rename src/app/{features/settings/addons/components => shared/components/addons}/addon-card/addon-card.component.ts (90%) create mode 100644 src/app/shared/components/addons/index.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index adbad36dd..5771b7b7a 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -153,6 +153,11 @@ export const routes: Routes = [ loadComponent: () => import('./features/project/settings/settings.component').then((mod) => mod.SettingsComponent), }, + { + path: 'addons', + loadComponent: () => + import('./features/project/addons/addons.component').then((mod) => mod.AddonsComponent), + }, ], }, { diff --git a/src/app/core/constants/nav-items.constant.ts b/src/app/core/constants/nav-items.constant.ts index c3c68d64f..3ad5ea87b 100644 --- a/src/app/core/constants/nav-items.constant.ts +++ b/src/app/core/constants/nav-items.constant.ts @@ -108,6 +108,10 @@ export const PROJECT_MENU_ITEMS: MenuItem[] = [ label: 'navigation.project.settings', routerLink: 'settings', }, + { + label: 'navigation.project.addons', + routerLink: 'addons', + }, ], }, ]; diff --git a/src/app/features/project/addons/addons.component.html b/src/app/features/project/addons/addons.component.html new file mode 100644 index 000000000..8311510c6 --- /dev/null +++ b/src/app/features/project/addons/addons.component.html @@ -0,0 +1,82 @@ + +
+ + @if (!isMobile()) { + + + {{ 'settings.addons.tabs.allAddons' | translate }} + + + {{ 'settings.addons.tabs.connectedAddons' | translate }} + + + } + + + @if (isMobile()) { + + + {{ selectedOption.label | translate }} + + + {{ item.label | translate }} + + + } + +

+ {{ 'settings.addons.description' | translate }} +

+
+
+ + + {{ selectedOption.label | translate }} + + + {{ item.label | translate }} + + +
+
+ +
+
+ + +
+ + + + + +
+
+
diff --git a/src/app/features/project/addons/addons.component.scss b/src/app/features/project/addons/addons.component.scss new file mode 100644 index 000000000..ef94601f7 --- /dev/null +++ b/src/app/features/project/addons/addons.component.scss @@ -0,0 +1,11 @@ +@use "assets/styles/mixins" as mix; +@use "assets/styles/variables" as var; + +:host { + @include mix.flex-column; + flex: 1; + + .tabs-container { + @include mix.flex-column; + } +} diff --git a/src/app/features/project/addons/addons.component.spec.ts b/src/app/features/project/addons/addons.component.spec.ts new file mode 100644 index 000000000..4d1a6de4c --- /dev/null +++ b/src/app/features/project/addons/addons.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AddonsComponent } from './addons.component'; + +describe('AddonsComponent', () => { + let component: AddonsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AddonsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AddonsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/project/addons/addons.component.ts b/src/app/features/project/addons/addons.component.ts new file mode 100644 index 000000000..d4205f3fd --- /dev/null +++ b/src/app/features/project/addons/addons.component.ts @@ -0,0 +1,134 @@ +import { Store } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Select } from 'primeng/select'; +import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; + +import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; + +import { UserSelectors } from '@core/store/user'; +import { + AddonsSelectors, + GetAddonsUserReference, + GetAuthorizedCitationAddons, + GetAuthorizedStorageAddons, + GetCitationAddons, + GetStorageAddons, +} from '@osf/features/settings/addons/store'; +import { SearchInputComponent, SubHeaderComponent } from '@shared/components'; +import { AddonCardListComponent } from '@shared/components/addons'; +import { SelectOption } from '@shared/models'; +import { IS_XSMALL } from '@shared/utils'; + +@Component({ + selector: 'osf-addons', + imports: [ + AddonCardListComponent, + SearchInputComponent, + Select, + SubHeaderComponent, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + TranslatePipe, + FormsModule, + ], + templateUrl: './addons.component.html', + styleUrl: './addons.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AddonsComponent { + #store = inject(Store); + protected readonly defaultTabValue = 0; + protected readonly isMobile = toSignal(inject(IS_XSMALL)); + protected readonly searchValue = signal(''); + protected readonly selectedCategory = signal('external-storage-services'); + protected readonly selectedTab = signal(this.defaultTabValue); + protected readonly currentUser = this.#store.selectSignal(UserSelectors.getCurrentUser); + protected readonly addonsUserReference = this.#store.selectSignal(AddonsSelectors.getAddonUserReference); + protected readonly storageAddons = this.#store.selectSignal(AddonsSelectors.getStorageAddons); + protected readonly citationAddons = this.#store.selectSignal(AddonsSelectors.getCitationAddons); + protected readonly authorizedStorageAddons = this.#store.selectSignal(AddonsSelectors.getAuthorizedStorageAddons); + protected readonly authorizedCitationAddons = this.#store.selectSignal(AddonsSelectors.getAuthorizedCitationAddons); + protected readonly allAuthorizedAddons = computed(() => { + const authorizedAddons = [...this.authorizedStorageAddons(), ...this.authorizedCitationAddons()]; + + const searchValue = this.searchValue().toLowerCase(); + return authorizedAddons.filter((card) => card.displayName.includes(searchValue)); + }); + + protected readonly userReferenceId = computed(() => { + return this.addonsUserReference()[0]?.id; + }); + + protected readonly currentAction = computed(() => + this.selectedCategory() === 'external-storage-services' ? GetStorageAddons : GetCitationAddons + ); + + protected readonly currentAddonsState = computed(() => + this.selectedCategory() === 'external-storage-services' ? this.storageAddons() : this.citationAddons() + ); + + protected readonly filteredAddonCards = computed(() => { + const searchValue = this.searchValue().toLowerCase(); + return this.currentAddonsState().filter((card) => card.externalServiceName.includes(searchValue)); + }); + + protected readonly tabOptions: SelectOption[] = [ + { + label: 'settings.addons.tabs.allAddons', + value: 0, + }, + { + label: 'settings.addons.tabs.connectedAddons', + value: 1, + }, + ]; + + protected readonly categoryOptions: SelectOption[] = [ + { + label: 'settings.addons.categories.additionalService', + value: 'external-storage-services', + }, + { + label: 'settings.addons.categories.citationManager', + value: 'external-citation-services', + }, + ]; + + protected onCategoryChange(value: string): void { + this.selectedCategory.set(value); + } + + constructor() { + effect(() => { + // Only proceed if we have the current user + if (this.currentUser()) { + this.#store.dispatch(GetAddonsUserReference); + } + }); + + effect(() => { + // Only proceed if we have both current user and user reference + if (this.currentUser() && this.userReferenceId()) { + this.#loadAddonsIfNeeded(this.userReferenceId()); + } + }); + } + + #loadAddonsIfNeeded(userReferenceId: string): void { + const action = this.currentAction(); + const addons = this.currentAddonsState(); + + if (!addons?.length) { + this.#store.dispatch(action); + this.#store.dispatch(new GetAuthorizedStorageAddons(userReferenceId)); + this.#store.dispatch(new GetAuthorizedCitationAddons(userReferenceId)); + } + } +} diff --git a/src/app/features/settings/addons/addons.component.spec.ts b/src/app/features/settings/addons/addons.component.spec.ts index b137bf14c..9ba6009f4 100644 --- a/src/app/features/settings/addons/addons.component.spec.ts +++ b/src/app/features/settings/addons/addons.component.spec.ts @@ -9,9 +9,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { UserSelectors } from '@osf/core/store/user'; import { SearchInputComponent, SubHeaderComponent } from '@osf/shared/components'; import { IS_XSMALL } from '@osf/shared/utils'; +import { AddonCardListComponent } from '@shared/components/addons'; import { AddonsComponent } from './addons.component'; -import { AddonCardListComponent } from './components'; import { AddonsSelectors } from './store'; describe('AddonsComponent', () => { diff --git a/src/app/features/settings/addons/addons.component.ts b/src/app/features/settings/addons/addons.component.ts index c52bd8cde..a81adf4df 100644 --- a/src/app/features/settings/addons/addons.component.ts +++ b/src/app/features/settings/addons/addons.component.ts @@ -14,8 +14,8 @@ import { UserSelectors } from '@osf/core/store/user'; import { SearchInputComponent, SubHeaderComponent } from '@osf/shared/components'; import { SelectOption } from '@osf/shared/models'; import { IS_XSMALL } from '@osf/shared/utils'; +import { AddonCardListComponent } from '@shared/components/addons'; -import { AddonCardListComponent } from './components'; import { AddonsSelectors, GetAddonsUserReference, diff --git a/src/app/features/settings/addons/components/index.ts b/src/app/features/settings/addons/components/index.ts deleted file mode 100644 index 58c98ce7b..000000000 --- a/src/app/features/settings/addons/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { AddonCardComponent } from './addon-card/addon-card.component'; -export { AddonCardListComponent } from './addon-card-list/addon-card-list.component'; diff --git a/src/app/features/settings/addons/components/addon-card-list/addon-card-list.component.html b/src/app/shared/components/addons/addon-card-list/addon-card-list.component.html similarity index 100% rename from src/app/features/settings/addons/components/addon-card-list/addon-card-list.component.html rename to src/app/shared/components/addons/addon-card-list/addon-card-list.component.html diff --git a/src/app/features/settings/addons/components/addon-card-list/addon-card-list.component.scss b/src/app/shared/components/addons/addon-card-list/addon-card-list.component.scss similarity index 100% rename from src/app/features/settings/addons/components/addon-card-list/addon-card-list.component.scss rename to src/app/shared/components/addons/addon-card-list/addon-card-list.component.scss diff --git a/src/app/features/settings/addons/components/addon-card-list/addon-card-list.component.spec.ts b/src/app/shared/components/addons/addon-card-list/addon-card-list.component.spec.ts similarity index 88% rename from src/app/features/settings/addons/components/addon-card-list/addon-card-list.component.spec.ts rename to src/app/shared/components/addons/addon-card-list/addon-card-list.component.spec.ts index b6941e236..5bf262df1 100644 --- a/src/app/features/settings/addons/components/addon-card-list/addon-card-list.component.spec.ts +++ b/src/app/shared/components/addons/addon-card-list/addon-card-list.component.spec.ts @@ -2,7 +2,7 @@ import { MockComponent } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { AddonCardComponent } from '../addon-card/addon-card.component'; +import { AddonCardComponent } from '@shared/components/addons/addon-card/addon-card.component'; import { AddonCardListComponent } from './addon-card-list.component'; diff --git a/src/app/features/settings/addons/components/addon-card-list/addon-card-list.component.ts b/src/app/shared/components/addons/addon-card-list/addon-card-list.component.ts similarity index 74% rename from src/app/features/settings/addons/components/addon-card-list/addon-card-list.component.ts rename to src/app/shared/components/addons/addon-card-list/addon-card-list.component.ts index e2da2a260..a502c0ebb 100644 --- a/src/app/features/settings/addons/components/addon-card-list/addon-card-list.component.ts +++ b/src/app/shared/components/addons/addon-card-list/addon-card-list.component.ts @@ -1,7 +1,7 @@ import { Component, input } from '@angular/core'; -import { Addon, AuthorizedAddon } from '../../models'; -import { AddonCardComponent } from '../addon-card/addon-card.component'; +import { Addon, AuthorizedAddon } from '@osf/features/settings/addons/models'; +import { AddonCardComponent } from '@shared/components/addons'; @Component({ selector: 'osf-addon-card-list', diff --git a/src/app/features/settings/addons/components/addon-card/addon-card.component.html b/src/app/shared/components/addons/addon-card/addon-card.component.html similarity index 100% rename from src/app/features/settings/addons/components/addon-card/addon-card.component.html rename to src/app/shared/components/addons/addon-card/addon-card.component.html diff --git a/src/app/features/settings/addons/components/addon-card/addon-card.component.scss b/src/app/shared/components/addons/addon-card/addon-card.component.scss similarity index 90% rename from src/app/features/settings/addons/components/addon-card/addon-card.component.scss rename to src/app/shared/components/addons/addon-card/addon-card.component.scss index 288440f8d..d2c4ec4e3 100644 --- a/src/app/features/settings/addons/components/addon-card/addon-card.component.scss +++ b/src/app/shared/components/addons/addon-card/addon-card.component.scss @@ -1,4 +1,4 @@ -@use "assets/styles/variables" as var; +@use "../../../../../assets/styles/variables" as var; :host { display: block; diff --git a/src/app/features/settings/addons/components/addon-card/addon-card.component.spec.ts b/src/app/shared/components/addons/addon-card/addon-card.component.spec.ts similarity index 89% rename from src/app/features/settings/addons/components/addon-card/addon-card.component.spec.ts rename to src/app/shared/components/addons/addon-card/addon-card.component.spec.ts index 84446d41c..5b04c559d 100644 --- a/src/app/features/settings/addons/components/addon-card/addon-card.component.spec.ts +++ b/src/app/shared/components/addons/addon-card/addon-card.component.spec.ts @@ -5,8 +5,8 @@ import { MockPipe, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CredentialsFormat } from '../../enums'; -import { Addon } from '../../models'; +import { CredentialsFormat } from '../../../../features/settings/addons/enums'; +import { Addon } from '../../../../features/settings/addons/models'; import { AddonCardComponent } from './addon-card.component'; diff --git a/src/app/features/settings/addons/components/addon-card/addon-card.component.ts b/src/app/shared/components/addons/addon-card/addon-card.component.ts similarity index 90% rename from src/app/features/settings/addons/components/addon-card/addon-card.component.ts rename to src/app/shared/components/addons/addon-card/addon-card.component.ts index 93308abe9..2fedde7cf 100644 --- a/src/app/features/settings/addons/components/addon-card/addon-card.component.ts +++ b/src/app/shared/components/addons/addon-card/addon-card.component.ts @@ -10,10 +10,10 @@ import { Component, computed, inject, input, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; -import { IS_XSMALL } from '@osf/shared/utils'; +import { IS_XSMALL } from '@shared/utils'; -import { Addon, AuthorizedAddon } from '../../models'; -import { DeleteAuthorizedAddon } from '../../store'; +import { Addon, AuthorizedAddon } from '../../../../features/settings/addons/models'; +import { DeleteAuthorizedAddon } from '../../../../features/settings/addons/store'; @Component({ selector: 'osf-addon-card', diff --git a/src/app/shared/components/addons/index.ts b/src/app/shared/components/addons/index.ts new file mode 100644 index 000000000..dee6639df --- /dev/null +++ b/src/app/shared/components/addons/index.ts @@ -0,0 +1,2 @@ +export { AddonCardComponent } from '@shared/components/addons/addon-card/addon-card.component'; +export { AddonCardListComponent } from '@shared/components/addons/addon-card-list/addon-card-list.component'; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 5991a5a29..7d1ee9529 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -26,7 +26,8 @@ "registrations": "Registrations", "settings": "Settings", "contributors": "Contributors", - "analytics": "Analytics" + "analytics": "Analytics", + "addons": "Add-ons" } }, "auth": { From 9797cb907fffde7f0bfdba6a38ad8066c9695a0f Mon Sep 17 00:00:00 2001 From: Kyrylo Petrov Date: Mon, 2 Jun 2025 18:36:00 +0300 Subject: [PATCH 02/18] fix(project-addons): minor fixes --- .../project/addons/addons.component.html | 54 +++++++++---------- .../project/addons/addons.component.scss | 4 -- .../project/addons/addons.component.ts | 49 ++++++----------- .../project/addons/addons.constants.ts | 33 ++++++++++++ .../addon-card/addon-card.component.scss | 2 +- .../addon-card/addon-card.component.spec.ts | 4 +- .../addons/addon-card/addon-card.component.ts | 5 +- 7 files changed, 80 insertions(+), 71 deletions(-) create mode 100644 src/app/features/project/addons/addons.constants.ts diff --git a/src/app/features/project/addons/addons.component.html b/src/app/features/project/addons/addons.component.html index 8311510c6..1beaf7a06 100644 --- a/src/app/features/project/addons/addons.component.html +++ b/src/app/features/project/addons/addons.component.html @@ -1,35 +1,31 @@
- - @if (!isMobile()) { - - - {{ 'settings.addons.tabs.allAddons' | translate }} - - - {{ 'settings.addons.tabs.connectedAddons' | translate }} - - - } +
- - + @if (!isAddonsLoading()) { + + } @else { +
+ +
+ } - + @if (!isAddonsLoading()) { + + } @else { +
+ +
+ }
diff --git a/src/app/features/project/addons/addons.component.ts b/src/app/features/project/addons/addons.component.ts index e02d63fd7..5da8e2f1d 100644 --- a/src/app/features/project/addons/addons.component.ts +++ b/src/app/features/project/addons/addons.component.ts @@ -1,29 +1,32 @@ -import { select, Store } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Select } from 'primeng/select'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, OnInit, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; import { UserSelectors } from '@core/store/user'; -import { SearchInputComponent, SubHeaderComponent } from '@shared/components'; +import { LoadingSpinnerComponent, SearchInputComponent, SubHeaderComponent } from '@shared/components'; import { AddonCardListComponent } from '@shared/components/addons'; +import { ADDON_CATEGORY_OPTIONS, ADDON_TAB_OPTIONS } from '@shared/constants'; +import { AddonCategory, AddonTabValue } from '@shared/enums'; import { AddonsSelectors, + DeleteAuthorizedAddon, + GetAddonsResourceReference, GetAddonsUserReference, - GetAuthorizedCitationAddons, - GetAuthorizedStorageAddons, GetCitationAddons, + GetConfiguredCitationAddons, + GetConfiguredStorageAddons, GetStorageAddons, } from '@shared/stores/addons'; import { IS_XSMALL } from '@shared/utils'; -import { ADDON_CATEGORY_OPTIONS, ADDON_TAB_OPTIONS, AddonCategoryValue, AddonTabValue } from './utils/addons.constants'; - @Component({ selector: 'osf-addons', imports: [ @@ -38,47 +41,84 @@ import { ADDON_CATEGORY_OPTIONS, ADDON_TAB_OPTIONS, AddonCategoryValue, AddonTab Tabs, TranslatePipe, FormsModule, + LoadingSpinnerComponent, ], templateUrl: './addons.component.html', styleUrl: './addons.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AddonsComponent { - protected readonly AddonTabValue = AddonTabValue; - #store = inject(Store); - protected readonly defaultTabValue = AddonTabValue.ALL_ADDONS; - protected readonly isMobile = toSignal(inject(IS_XSMALL)); - protected readonly searchControl = new FormControl(''); - protected readonly selectedCategory = signal(AddonCategoryValue.EXTERNAL_STORAGE_SERVICES); - protected readonly selectedTab = signal(this.defaultTabValue); - protected readonly currentUser = select(UserSelectors.getCurrentUser); - protected readonly addonsUserReference = select(AddonsSelectors.getAddonUserReference); - protected readonly storageAddons = select(AddonsSelectors.getStorageAddons); - protected readonly citationAddons = select(AddonsSelectors.getCitationAddons); - protected readonly authorizedStorageAddons = select(AddonsSelectors.getAuthorizedStorageAddons); - protected readonly authorizedCitationAddons = select(AddonsSelectors.getAuthorizedCitationAddons); - protected readonly allAuthorizedAddons = computed(() => { - const authorizedAddons = [...this.authorizedStorageAddons(), ...this.authorizedCitationAddons()]; +export class AddonsComponent implements OnInit { + private route = inject(ActivatedRoute); + protected readonly tabOptions = ADDON_TAB_OPTIONS; + protected readonly categoryOptions = ADDON_CATEGORY_OPTIONS; + protected isMobile = toSignal(inject(IS_XSMALL)); + protected AddonTabValue = AddonTabValue; + protected defaultTabValue = AddonTabValue.ALL_ADDONS; + protected searchControl = new FormControl(''); + protected selectedCategory = signal(AddonCategory.EXTERNAL_STORAGE_SERVICES); + protected selectedTab = signal(this.defaultTabValue); + + protected currentUser = select(UserSelectors.getCurrentUser); + protected addonsResourceReference = select(AddonsSelectors.getAddonsResourceReference); + protected addonsUserReference = select(AddonsSelectors.getAddonsUserReference); + protected storageAddons = select(AddonsSelectors.getStorageAddons); + protected citationAddons = select(AddonsSelectors.getCitationAddons); + protected configuredStorageAddons = select(AddonsSelectors.getConfiguredStorageAddons); + protected configuredCitationAddons = select(AddonsSelectors.getConfiguredCitationAddons); + + protected isCurrentUserLoading = select(UserSelectors.getCurrentUserLoading); + protected isUserReferenceLoading = select(AddonsSelectors.getAddonsUserReferenceLoading); + protected isResourceReferenceLoading = select(AddonsSelectors.getAddonsResourceReferenceLoading); + protected isStorageAddonsLoading = select(AddonsSelectors.getStorageAddonsLoading); + protected isCitationAddonsLoading = select(AddonsSelectors.getCitationAddonsLoading); + protected isConfiguredStorageAddonsLoading = select(AddonsSelectors.getConfiguredStorageAddonsLoading); + protected isConfiguredCitationAddonsLoading = select(AddonsSelectors.getConfiguredCitationAddonsLoading); + protected isAddonsLoading = computed(() => { + return ( + this.isStorageAddonsLoading() || + this.isCitationAddonsLoading() || + this.isUserReferenceLoading() || + this.isResourceReferenceLoading() || + this.isCurrentUserLoading() + ); + }); - const searchValue = this.searchControl.value?.toLowerCase() ?? ''; - return authorizedAddons.filter((card) => card.displayName.includes(searchValue)); + protected actions = createDispatchMap({ + getStorageAddons: GetStorageAddons, + getCitationAddons: GetCitationAddons, + getConfiguredStorageAddons: GetConfiguredStorageAddons, + getConfiguredCitationAddons: GetConfiguredCitationAddons, + getAddonsUserReference: GetAddonsUserReference, + getAddonsResourceReference: GetAddonsResourceReference, + deleteAuthorizedAddon: DeleteAuthorizedAddon, }); protected readonly userReferenceId = computed(() => { return this.addonsUserReference()[0]?.id; }); - protected readonly currentAction = computed(() => - this.selectedCategory() === AddonCategoryValue.EXTERNAL_STORAGE_SERVICES ? GetStorageAddons : GetCitationAddons + protected allConfiguredAddons = computed(() => { + const authorizedAddons = [...this.configuredStorageAddons(), ...this.configuredCitationAddons()]; + + const searchValue = this.searchControl.value?.toLowerCase() ?? ''; + return authorizedAddons.filter((card) => card.displayName.includes(searchValue)); + }); + + protected resourceReferenceId = computed(() => { + return this.addonsResourceReference()[0]?.id; + }); + + protected currentAction = computed(() => + this.selectedCategory() === AddonCategory.EXTERNAL_STORAGE_SERVICES + ? this.actions.getStorageAddons + : this.actions.getCitationAddons ); - protected readonly currentAddonsState = computed(() => - this.selectedCategory() === AddonCategoryValue.EXTERNAL_STORAGE_SERVICES - ? this.storageAddons() - : this.citationAddons() + protected currentAddonsState = computed(() => + this.selectedCategory() === AddonCategory.EXTERNAL_STORAGE_SERVICES ? this.storageAddons() : this.citationAddons() ); - protected readonly filteredAddonCards = computed(() => { + protected filteredAddonCards = computed(() => { const searchValue = this.searchControl.value?.toLowerCase() ?? ''; return this.currentAddonsState().filter( (card) => @@ -87,37 +127,46 @@ export class AddonsComponent { ); }); - protected readonly tabOptions = ADDON_TAB_OPTIONS; - protected readonly categoryOptions = ADDON_CATEGORY_OPTIONS; - protected onCategoryChange(value: string): void { this.selectedCategory.set(value); } constructor() { effect(() => { - // Only proceed if we have the current user - if (this.currentUser()) { - this.#store.dispatch(GetAddonsUserReference); + if (this.currentUser() && !this.userReferenceId()) { + this.actions.getAddonsUserReference(); } }); effect(() => { - // Only proceed if we have both current user and user reference if (this.currentUser() && this.userReferenceId()) { - this.#loadAddonsIfNeeded(this.userReferenceId()); + const action = this.currentAction(); + const addons = this.currentAddonsState(); + + if (!addons?.length) { + action(); + } + } + }); + + effect(() => { + const resourceReferenceId = this.resourceReferenceId(); + if (resourceReferenceId) { + this.fetchAllConfiguredAddons(resourceReferenceId); } }); } - #loadAddonsIfNeeded(userReferenceId: string): void { - const action = this.currentAction(); - const addons = this.currentAddonsState(); + ngOnInit(): void { + const projectId = this.route.parent?.parent?.snapshot.params['id']; - if (!addons?.length) { - this.#store.dispatch(action); - this.#store.dispatch(new GetAuthorizedStorageAddons(userReferenceId)); - this.#store.dispatch(new GetAuthorizedCitationAddons(userReferenceId)); + if (projectId && !this.addonsResourceReference()) { + this.actions.getAddonsResourceReference(projectId); } } + + private fetchAllConfiguredAddons(resourceReferenceId: string): void { + this.actions.getConfiguredStorageAddons(resourceReferenceId); + this.actions.getConfiguredCitationAddons(resourceReferenceId); + } } diff --git a/src/app/features/project/addons/addons.constants.ts b/src/app/features/project/addons/addons.constants.ts deleted file mode 100644 index 8d4941de7..000000000 --- a/src/app/features/project/addons/addons.constants.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { SelectOption } from '@shared/models'; - -export enum AddonTabValue { - ALL_ADDONS = 0, - CONNECTED_ADDONS = 1, -} - -export enum AddonCategoryValue { - EXTERNAL_STORAGE_SERVICES = 'external-storage-services', - EXTERNAL_CITATION_SERVICES = 'external-citation-services', -} - -export const ADDON_TAB_OPTIONS: SelectOption[] = [ - { - label: 'settings.addons.tabs.allAddons', - value: AddonTabValue.ALL_ADDONS, - }, - { - label: 'settings.addons.tabs.connectedAddons', - value: AddonTabValue.CONNECTED_ADDONS, - }, -]; - -export const ADDON_CATEGORY_OPTIONS: SelectOption[] = [ - { - label: 'settings.addons.categories.additionalService', - value: AddonCategoryValue.EXTERNAL_STORAGE_SERVICES, - }, - { - label: 'settings.addons.categories.citationManager', - value: AddonCategoryValue.EXTERNAL_CITATION_SERVICES, - }, -]; diff --git a/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.html b/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.html new file mode 100644 index 000000000..109ed5008 --- /dev/null +++ b/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.html @@ -0,0 +1,303 @@ + +
+ + + + +
+

+ {{ addon()?.providerName }} + {{ 'settings.addons.connectAddon.terms' | translate }} +

+ +
+ + + + + {{ 'settings.addons.connectAddon.table.function' | translate }} + + + {{ 'settings.addons.connectAddon.table.status' | translate }} + + + + + + {{ term.function }} + {{ term.status }} + + + +
+ +
+

+ {{ 'settings.addons.connectAddon.termsDescription' | translate }} +

+

+ {{ 'settings.addons.connectAddon.storageDescription' | translate }} +

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

{{ loginOrChooseAccountText() }}

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

+ {{ 'settings.addons.connectAddon.chooseExistingAccount' | translate }} {{ addon()?.displayName }} +

+
    + @for (account of currentAuthorizedAddonAccounts(); track account.id) { +
  • + + +
  • + } +
+
+ + +
+
+
+
+ + + +
+

{{ 'settings.addons.connectAddon.setupNewAccount' | translate }}

+ + @if (addon()?.credentialsFormat === credentialsFormat.ACCESS_SECRET_KEYS) { +

+ {{ 'settings.addons.form.fields.accessKey' | translate }} +

+ +

+ {{ 'settings.addons.form.fields.secretKey' | translate }} +

+ + } + + @if (addon()?.credentialsFormat === credentialsFormat.DATAVERSE_API_TOKEN) { +

+ {{ 'settings.addons.form.fields.hostUrl' | translate }} +

+

+ {{ 'settings.addons.form.fields.hostUrlDescription' | translate }} +

+ +

+ {{ 'settings.addons.form.fields.personalAccessToken' | translate }} +

+ + } + + @if (addon()?.credentialsFormat === credentialsFormat.USERNAME_PASSWORD) { +

+ {{ 'settings.addons.form.fields.hostUrl' | translate }} +

+

+ {{ 'settings.addons.form.fields.hostUrlDescription' | translate }} +

+ +

+ {{ 'settings.addons.form.fields.username' | translate }} +

+ +

+ {{ 'settings.addons.form.fields.password' | translate }} +

+

+ {{ 'settings.addons.form.fields.passwordDescription' | translate }} +

+
+ +
+ } + + @if (addon()?.credentialsFormat === credentialsFormat.REPO_TOKEN) { +

+ {{ 'settings.addons.form.fields.accessKey' | translate }} +

+ +

+ {{ 'settings.addons.form.fields.secretKey' | translate }} +

+ + } + +

+ {{ 'settings.addons.form.fields.accountName' | translate }} +

+

+ {{ 'settings.addons.form.fields.accountNameDescription' | translate }} +

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

+ {{ 'settings.addons.connectAddon.setupNewAccount' | translate }} +

+ +
+ +

+ {{ 'settings.addons.connectAddon.oauthDescription' | translate }} +

+ + {{ 'settings.addons.connectAddon.startOauth' | translate }} + +
+
+
+
+
+
diff --git a/src/app/features/settings/addons/pages/connect-addon/connect-addon.component.scss b/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.scss similarity index 100% rename from src/app/features/settings/addons/pages/connect-addon/connect-addon.component.scss rename to src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.scss diff --git a/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.spec.ts b/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.spec.ts new file mode 100644 index 000000000..a7df98958 --- /dev/null +++ b/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.spec.ts @@ -0,0 +1,83 @@ +import { Store } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { ActivatedRoute, Navigation, Router, UrlTree } from '@angular/router'; + +import { SubHeaderComponent } from '@osf/shared/components'; +import { AddonsSelectors } from '@shared/stores/addons'; + +import { CredentialsFormat } from '../../enums'; +import { Addon } from '../../models'; + +import { ConnectConfigureAddonComponent } from './connect-configure-addon.component'; + +describe('ConnectAddonComponent', () => { + let component: ConnectConfigureAddonComponent; + let fixture: ComponentFixture; + + const mockAddon: Addon = { + id: 'test-addon-id', + type: 'external-storage-services', + displayName: 'Test Addon', + credentialsFormat: CredentialsFormat.OAUTH2, + supportedFeatures: ['ACCESS'], + providerName: 'Test Provider', + authUrl: 'https://test.com/auth', + externalServiceName: 'test-service', + }; + + beforeEach(async () => { + const mockNavigation: Partial = { + id: 1, + initialUrl: new UrlTree(), + extractedUrl: new UrlTree(), + trigger: 'imperative', + previousNavigation: null, + extras: { + state: { addon: mockAddon }, + }, + }; + + await TestBed.configureTestingModule({ + imports: [ConnectConfigureAddonComponent, MockComponent(SubHeaderComponent), MockPipe(TranslatePipe)], + providers: [ + provideNoopAnimations(), + MockProvider(Store, { + selectSignal: jest.fn().mockImplementation((selector) => { + if (selector === AddonsSelectors.getAddonsUserReference) { + return () => [{ id: 'test-user-id' }]; + } + if (selector === AddonsSelectors.getCreatedOrUpdatedAuthorizedAddon) { + return () => null; + } + return () => null; + }), + dispatch: jest.fn().mockReturnValue(of({})), + }), + MockProvider(Router, { + getCurrentNavigation: () => mockNavigation as Navigation, + navigate: jest.fn(), + }), + MockProvider(TranslateService), + MockProvider(ActivatedRoute), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ConnectConfigureAddonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create and initialize with addon data from router state', () => { + expect(component).toBeTruthy(); + expect(component['addon']()).toEqual(mockAddon); + expect(component['terms']().length).toBeGreaterThan(0); + expect(component['addonForm']).toBeTruthy(); + }); +}); diff --git a/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.ts b/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.ts new file mode 100644 index 000000000..2176e0936 --- /dev/null +++ b/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.ts @@ -0,0 +1,335 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Card } from 'primeng/card'; +import { InputText } from 'primeng/inputtext'; +import { Password } from 'primeng/password'; +import { RadioButtonModule } from 'primeng/radiobutton'; +import { StepPanel, StepPanels, Stepper } from 'primeng/stepper'; +import { TableModule } from 'primeng/table'; + +import { NgClass } from '@angular/common'; +import { Component, computed, inject, signal, viewChild } from '@angular/core'; +import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; + +import { AddonConfigMap } from '@osf/features/project/addons/utils'; +import { SubHeaderComponent } from '@osf/shared/components'; +import { ADDON_TERMS as addonTerms } from '@osf/shared/constants'; +import { AddonFormControls, CredentialsFormat, ProjectAddonsStepperValue } from '@osf/shared/enums'; +import { SettingsAddonsStepperValue } from '@shared/enums/settings-addons-stepper.enum'; +import { Addon, AddonForm, AddonRequest, AddonTerm, AuthorizedAddon } from '@shared/models'; +import { + AddonsSelectors, + CreateAuthorizedAddon, + GetAuthorizedCitationAddons, + GetAuthorizedStorageAddons, + UpdateAuthorizedAddon, +} from '@shared/stores/addons'; + +@Component({ + selector: 'osf-connect-configure-addon', + imports: [ + SubHeaderComponent, + StepPanel, + StepPanels, + Stepper, + Button, + TableModule, + RouterLink, + NgClass, + Card, + FormsModule, + ReactiveFormsModule, + InputText, + Password, + TranslatePipe, + RadioButtonModule, + ], + templateUrl: './connect-configure-addon.component.html', + providers: [RadioButtonModule], + styleUrl: './connect-configure-addon.component.scss', +}) +export class ConnectConfigureAddonComponent { + private translateService = inject(TranslateService); + private router = inject(Router); + private fb = inject(FormBuilder); + protected stepper = viewChild(Stepper); + protected AddonStepperValue = ProjectAddonsStepperValue; + protected credentialsFormat = CredentialsFormat; + protected formControls = AddonFormControls; + protected terms = signal([]); + protected addon = signal(null); + protected addonAuthUrl = signal('/settings/addons'); + protected currentAuthorizedAddonAccounts = signal([]); + protected chosenAccount = ''; + + protected addonsUserReference = select(AddonsSelectors.getAddonsUserReference); + protected createdAddon = select(AddonsSelectors.getCreatedOrUpdatedAuthorizedAddon); + protected authorizedStorageAddons = select(AddonsSelectors.getAuthorizedStorageAddons); + protected authorizedCitationAddons = select(AddonsSelectors.getAuthorizedCitationAddons); + + protected isAuthorizedStorageAddonsLoading = select(AddonsSelectors.getAuthorizedStorageAddonsLoading); + protected isAuthorizedCitationAddonsLoading = select(AddonsSelectors.getAuthorizedCitationAddonsLoading); + protected isAddonConnecting = select(AddonsSelectors.getCreatedOrUpdatedStorageAddonSubmitting); + + protected actions = createDispatchMap({ + getAuthorizedStorageAddons: GetAuthorizedStorageAddons, + getAuthorizedCitationAddons: GetAuthorizedCitationAddons, + createAuthorizedAddon: CreateAuthorizedAddon, + updateAuthorizedAddon: UpdateAuthorizedAddon, + }); + + protected readonly userReferenceId = computed(() => { + return this.addonsUserReference()[0]?.id; + }); + + protected loginOrChooseAccountText = computed(() => { + return this.translateService.instant('settings.addons.connectAddon.loginToOrSelectAccount', { + addonName: this.addon()?.displayName, + }); + }); + + // protected isAuthorized = computed(() => { + // //check if the addon is already authorized + // const addon = this.addon(); + // if (addon) { + // return addon.type === 'authorized-storage-accounts' || addon.type === 'authorized-citation-accounts'; + // } + // return false; + // }); + + protected addonTypeString = computed(() => { + //get the addon type string based on the addon type property + const addon = this.addon(); + if (addon) { + return addon.type === 'external-storage-services' || addon.type === 'authorized-storage-accounts' + ? 'storage' + : 'citation'; + } + return ''; + }); + protected addonForm: FormGroup; + + protected readonly baseUrl = computed(() => { + const currentUrl = this.router.url; + return currentUrl.split('/addons')[0]; + }); + + constructor() { + const terms = this.getTerms(); + this.terms.set(terms); + this.addonForm = this.initializeForm(); + } + + protected handleConnectAddon(): void { + if (!this.addon() || !this.addonForm.valid) return; + + const request = this.generateRequestPayload(); + + this.actions.createAuthorizedAddon(request, this.addonTypeString()).subscribe({ + complete: () => { + const createdAddon = this.createdAddon(); + if (createdAddon) { + this.addonAuthUrl.set(createdAddon.attributes.auth_url); + window.open(createdAddon.attributes.auth_url, '_blank'); + this.stepper()?.value.set(SettingsAddonsStepperValue.AUTH); + } + }, + }); + } + + protected handleAuthorizedAccountsPresenceCheck() { + const addonType = this.addonTypeString(); + const referenceId = this.userReferenceId(); + const currentAddon = this.addon(); + + if (!addonType || !referenceId || !currentAddon) return; + + const addonConfig: AddonConfigMap = { + storage: { + getAddons: () => this.actions.getAuthorizedStorageAddons(referenceId), + getAuthorizedAddons: () => this.authorizedStorageAddons(), + }, + citation: { + getAddons: () => this.actions.getAuthorizedCitationAddons(referenceId), + getAuthorizedAddons: () => this.authorizedCitationAddons(), + }, + }; + + const selectedConfig = addonConfig[addonType]; + if (!selectedConfig) return; + + selectedConfig.getAddons().subscribe({ + complete: () => { + const authorizedAddons = selectedConfig.getAuthorizedAddons(); + const hasMatchingAddon = authorizedAddons.some( + (addon) => addon.externalServiceName === currentAddon.externalServiceName + ); + + if (hasMatchingAddon && authorizedAddons.length) { + const addonAccounts = authorizedAddons.filter( + (addon) => addon.externalServiceName === currentAddon.externalServiceName + ); + + this.currentAuthorizedAddonAccounts.set(addonAccounts); + } + + const nextStep = hasMatchingAddon + ? ProjectAddonsStepperValue.CHOOSE_CONNECTION + : ProjectAddonsStepperValue.SETUP_NEW_ACCOUNT; + + this.stepper()?.value.set(nextStep); + }, + }); + } + + private initializeForm(): FormGroup { + const addon = this.addon(); + + if (addon) { + const formControls: Partial = { + [AddonFormControls.AccountName]: this.fb.control(addon.displayName || '', Validators.required), + }; + + switch (addon.credentialsFormat) { + case CredentialsFormat.ACCESS_SECRET_KEYS: + formControls[AddonFormControls.AccessKey] = this.fb.control('', Validators.required); + formControls[AddonFormControls.SecretKey] = this.fb.control('', Validators.required); + break; + case CredentialsFormat.DATAVERSE_API_TOKEN: + formControls[AddonFormControls.HostUrl] = this.fb.control('', Validators.required); + formControls[AddonFormControls.PersonalAccessToken] = this.fb.control('', Validators.required); + break; + case CredentialsFormat.USERNAME_PASSWORD: + formControls[AddonFormControls.HostUrl] = this.fb.control('', Validators.required); + formControls[AddonFormControls.Username] = this.fb.control('', Validators.required); + formControls[AddonFormControls.Password] = this.fb.control('', Validators.required); + break; + case CredentialsFormat.REPO_TOKEN: + formControls[AddonFormControls.AccessKey] = this.fb.control('', Validators.required); + formControls[AddonFormControls.SecretKey] = this.fb.control('', Validators.required); + break; + } + return this.fb.group(formControls as AddonForm); + } + + return new FormGroup({} as AddonForm); + } + + private generateRequestPayload(): AddonRequest { + const formValue = this.addonForm.value; + const addon = this.addon()!; + const credentials: Record = {}; + const initiateOAuth = + addon.credentialsFormat === CredentialsFormat.OAUTH2 || addon.credentialsFormat === CredentialsFormat.OAUTH; + + switch (addon.credentialsFormat) { + case CredentialsFormat.ACCESS_SECRET_KEYS: + credentials['access_key'] = formValue[AddonFormControls.AccessKey]; + credentials['secret_key'] = formValue[AddonFormControls.SecretKey]; + break; + case CredentialsFormat.DATAVERSE_API_TOKEN: + credentials['personal_access_token'] = formValue[AddonFormControls.PersonalAccessToken]; + break; + case CredentialsFormat.USERNAME_PASSWORD: + credentials['username'] = formValue[AddonFormControls.Username]; + credentials['password'] = formValue[AddonFormControls.Password]; + break; + case CredentialsFormat.REPO_TOKEN: + credentials['access_key'] = formValue[AddonFormControls.AccessKey]; + credentials['secret_key'] = formValue[AddonFormControls.SecretKey]; + break; + } + + const requestPayload: AddonRequest = { + data: { + id: addon.id || '', + attributes: { + api_base_url: formValue[AddonFormControls.HostUrl] || '', + display_name: formValue[AddonFormControls.AccountName] || '', + authorized_capabilities: ['ACCESS', 'UPDATE'], + credentials, + initiate_oauth: initiateOAuth, + auth_url: null, + credentials_available: false, + }, + relationships: { + account_owner: { + data: { + type: 'user-references', + id: this.addonsUserReference()[0].id || '', + }, + }, + ...this.getServiceRelationship(addon), + }, + type: `authorized-${this.addonTypeString()}-accounts`, + }, + }; + + return requestPayload; + } + + private getServiceRelationship(addon: Addon | AuthorizedAddon) { + const isAuthorizedAddon = + addon.type === 'authorized-storage-accounts' || addon.type === 'authorized-citation-accounts'; + const addonId = isAuthorizedAddon ? (addon as AuthorizedAddon).externalStorageServiceId : (addon as Addon).id; + const addonType = this.addonTypeString(); + + return { + [`external_${addonType}_service`]: { + data: { + type: `external-${addonType}-services`, + id: addonId, + }, + }, + }; + } + + private getTerms(): AddonTerm[] { + const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as Addon | AuthorizedAddon; + if (!addon) { + this.router.navigate([`${this.baseUrl()}/addons`]); + } + + this.addon.set(addon); + const supportedFeatures = addon.supportedFeatures; + const provider = addon.providerName; + const isCitationService = addon.type === 'external-citation-services'; + + const relevantTerms = isCitationService ? addonTerms.filter((term) => term.citation) : addonTerms; + + return relevantTerms.map((term) => { + const feature = term.supportedFeature; + const hasFeature = supportedFeatures.includes(feature); + const hasPartialFeature = supportedFeatures.includes(`${feature}_PARTIAL`); + + let message: string; + let type: 'warning' | 'info' | 'danger'; + + if (isCitationService && term.citation) { + if (hasPartialFeature && term.citation.partial) { + message = term.citation.partial; + type = 'warning'; + } else if (!hasFeature && term.citation.false) { + message = term.citation.false; + type = 'danger'; + } else { + message = term.storage[hasFeature ? 'true' : 'false']; + type = hasFeature ? 'info' : 'danger'; + } + } else { + message = term.storage[hasFeature ? 'true' : 'false']; + type = hasFeature ? 'info' : hasPartialFeature ? 'warning' : 'danger'; + } + + return { + function: term.label, + status: message.replace(/{provider}/g, provider), + type, + }; + }); + } +} diff --git a/src/app/features/project/addons/components/index.ts b/src/app/features/project/addons/components/index.ts new file mode 100644 index 000000000..b2129e835 --- /dev/null +++ b/src/app/features/project/addons/components/index.ts @@ -0,0 +1 @@ +export { ConnectConfigureAddonComponent } from '@osf/features/project/addons/components/connect-configure-addon/connect-configure-addon.component'; diff --git a/src/app/features/project/addons/models/addon-config-actions.interface.ts b/src/app/features/project/addons/models/addon-config-actions.interface.ts new file mode 100644 index 000000000..418532f89 --- /dev/null +++ b/src/app/features/project/addons/models/addon-config-actions.interface.ts @@ -0,0 +1,8 @@ +import { Observable } from 'rxjs'; + +import { AuthorizedAddon } from '@shared/models'; + +export interface AddonConfigActions { + getAddons: () => Observable; + getAuthorizedAddons: () => AuthorizedAddon[]; +} diff --git a/src/app/features/project/addons/models/index.ts b/src/app/features/project/addons/models/index.ts new file mode 100644 index 000000000..734d2bbc0 --- /dev/null +++ b/src/app/features/project/addons/models/index.ts @@ -0,0 +1 @@ +export * from './addon-config-actions.interface'; diff --git a/src/app/features/project/addons/utils/addon-config-map.type.ts b/src/app/features/project/addons/utils/addon-config-map.type.ts new file mode 100644 index 000000000..54007dfeb --- /dev/null +++ b/src/app/features/project/addons/utils/addon-config-map.type.ts @@ -0,0 +1,3 @@ +import { AddonConfigActions } from '@osf/features/project/addons/models'; + +export type AddonConfigMap = Record; diff --git a/src/app/features/project/addons/utils/addons.constants.ts b/src/app/features/project/addons/utils/addons.constants.ts deleted file mode 100644 index 8d4941de7..000000000 --- a/src/app/features/project/addons/utils/addons.constants.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { SelectOption } from '@shared/models'; - -export enum AddonTabValue { - ALL_ADDONS = 0, - CONNECTED_ADDONS = 1, -} - -export enum AddonCategoryValue { - EXTERNAL_STORAGE_SERVICES = 'external-storage-services', - EXTERNAL_CITATION_SERVICES = 'external-citation-services', -} - -export const ADDON_TAB_OPTIONS: SelectOption[] = [ - { - label: 'settings.addons.tabs.allAddons', - value: AddonTabValue.ALL_ADDONS, - }, - { - label: 'settings.addons.tabs.connectedAddons', - value: AddonTabValue.CONNECTED_ADDONS, - }, -]; - -export const ADDON_CATEGORY_OPTIONS: SelectOption[] = [ - { - label: 'settings.addons.categories.additionalService', - value: AddonCategoryValue.EXTERNAL_STORAGE_SERVICES, - }, - { - label: 'settings.addons.categories.citationManager', - value: AddonCategoryValue.EXTERNAL_CITATION_SERVICES, - }, -]; diff --git a/src/app/features/project/addons/utils/index.ts b/src/app/features/project/addons/utils/index.ts new file mode 100644 index 000000000..298c14eb3 --- /dev/null +++ b/src/app/features/project/addons/utils/index.ts @@ -0,0 +1 @@ +export * from './addon-config-map.type'; diff --git a/src/app/features/project/overview/store/project-overview.selectors.ts b/src/app/features/project/overview/store/project-overview.selectors.ts index e7844e877..b385f6d20 100644 --- a/src/app/features/project/overview/store/project-overview.selectors.ts +++ b/src/app/features/project/overview/store/project-overview.selectors.ts @@ -29,11 +29,6 @@ export class ProjectOverviewSelectors { return state.components.isSubmitting; } - @Selector([ProjectOverviewState]) - static getComponentsError(state: ProjectOverviewStateModel) { - return state.components.error; - } - @Selector([ProjectOverviewState]) static getLinkedProjects(state: ProjectOverviewStateModel) { return state.linkedProjects.data; diff --git a/src/app/features/settings/addons/addons.component.html b/src/app/features/settings/addons/addons.component.html index e0319791b..34d9ca485 100644 --- a/src/app/features/settings/addons/addons.component.html +++ b/src/app/features/settings/addons/addons.component.html @@ -29,7 +29,7 @@ } - +

{{ 'settings.addons.description' | translate }}

@@ -60,9 +60,13 @@ - + @if (!isAddonsLoading()) { + + } @else { + + }
- + { if (selector === UserSelectors.getCurrentUser) { return () => ({ id: 'test-user-id' }); } - if (selector === AddonsSelectors.getAddonUserReference) { + if (selector === AddonsSelectors.getAddonsUserReference) { return () => [{ id: 'test-reference-id' }]; } if (selector === AddonsSelectors.getStorageAddons) { diff --git a/src/app/features/settings/addons/addons.component.ts b/src/app/features/settings/addons/addons.component.ts index 2b459f22f..6c5d78af0 100644 --- a/src/app/features/settings/addons/addons.component.ts +++ b/src/app/features/settings/addons/addons.component.ts @@ -1,4 +1,4 @@ -import { Store } from '@ngxs/store'; +import { createDispatchMap, select, Store } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; @@ -11,17 +11,21 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule } from '@angular/forms'; import { UserSelectors } from '@osf/core/store/user'; -import { SearchInputComponent, SubHeaderComponent } from '@osf/shared/components'; -import { SelectOption } from '@osf/shared/models'; +import { LoadingSpinnerComponent, SearchInputComponent, SubHeaderComponent } from '@osf/shared/components'; import { IS_XSMALL } from '@osf/shared/utils'; import { AddonCardListComponent } from '@shared/components/addons'; +import { ADDON_CATEGORY_OPTIONS, ADDON_TAB_OPTIONS } from '@shared/constants'; +import { AddonCategory, AddonTabValue } from '@shared/enums'; import { AddonsSelectors, + CreateAuthorizedAddon, + DeleteAuthorizedAddon, GetAddonsUserReference, GetAuthorizedCitationAddons, GetAuthorizedStorageAddons, GetCitationAddons, GetStorageAddons, + UpdateAuthorizedAddon, } from '@shared/stores/addons'; @Component({ @@ -39,25 +43,48 @@ import { SelectModule, FormsModule, TranslatePipe, + LoadingSpinnerComponent, ], templateUrl: './addons.component.html', styleUrl: './addons.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AddonsComponent { - #store = inject(Store); - protected readonly defaultTabValue = 0; - protected readonly isMobile = toSignal(inject(IS_XSMALL)); - protected readonly searchControl = new FormControl(''); - - protected readonly selectedCategory = signal('external-storage-services'); - protected readonly selectedTab = signal(this.defaultTabValue); - protected readonly currentUser = this.#store.selectSignal(UserSelectors.getCurrentUser); - protected readonly addonsUserReference = this.#store.selectSignal(AddonsSelectors.getAddonUserReference); - protected readonly storageAddons = this.#store.selectSignal(AddonsSelectors.getStorageAddons); - protected readonly citationAddons = this.#store.selectSignal(AddonsSelectors.getCitationAddons); - protected readonly authorizedStorageAddons = this.#store.selectSignal(AddonsSelectors.getAuthorizedStorageAddons); - protected readonly authorizedCitationAddons = this.#store.selectSignal(AddonsSelectors.getAuthorizedCitationAddons); + private store = inject(Store); + protected readonly tabOptions = ADDON_TAB_OPTIONS; + protected readonly categoryOptions = ADDON_CATEGORY_OPTIONS; + protected isMobile = toSignal(inject(IS_XSMALL)); + protected AddonTabValue = AddonTabValue; + protected defaultTabValue = AddonTabValue.ALL_ADDONS; + protected searchControl = new FormControl(''); + protected selectedCategory = signal(AddonCategory.EXTERNAL_STORAGE_SERVICES); + protected selectedTab = signal(this.defaultTabValue); + + protected currentUser = select(UserSelectors.getCurrentUser); + protected addonsUserReference = select(AddonsSelectors.getAddonsUserReference); + protected storageAddons = select(AddonsSelectors.getStorageAddons); + protected citationAddons = select(AddonsSelectors.getCitationAddons); + protected authorizedStorageAddons = select(AddonsSelectors.getAuthorizedStorageAddons); + protected authorizedCitationAddons = select(AddonsSelectors.getAuthorizedCitationAddons); + + protected isStorageAddonsLoading = select(AddonsSelectors.getStorageAddonsLoading); + protected isCitationAddonsLoading = select(AddonsSelectors.getCitationAddonsLoading); + protected isAuthorizedStorageAddonsLoading = select(AddonsSelectors.getAuthorizedStorageAddonsLoading); + protected isAuthorizedCitationAddonsLoading = select(AddonsSelectors.getAuthorizedCitationAddonsLoading); + protected isAddonsLoading = computed(() => { + return this.isStorageAddonsLoading() || this.isCitationAddonsLoading(); + }); + protected actions = createDispatchMap({ + getStorageAddons: GetStorageAddons, + getCitationAddons: GetCitationAddons, + getAuthorizedStorageAddons: GetAuthorizedStorageAddons, + getAuthorizedCitationAddons: GetAuthorizedCitationAddons, + createAuthorizedAddon: CreateAuthorizedAddon, + updateAuthorizedAddon: UpdateAuthorizedAddon, + getAddonsUserReference: GetAddonsUserReference, + deleteAuthorizedAddon: DeleteAuthorizedAddon, + }); + protected readonly allAuthorizedAddons = computed(() => { const authorizedAddons = [...this.authorizedStorageAddons(), ...this.authorizedCitationAddons()]; @@ -70,11 +97,13 @@ export class AddonsComponent { }); protected readonly currentAction = computed(() => - this.selectedCategory() === 'external-storage-services' ? GetStorageAddons : GetCitationAddons + this.selectedCategory() === AddonCategory.EXTERNAL_STORAGE_SERVICES + ? this.actions.getStorageAddons + : this.actions.getCitationAddons ); protected readonly currentAddonsState = computed(() => - this.selectedCategory() === 'external-storage-services' ? this.storageAddons() : this.citationAddons() + this.selectedCategory() === AddonCategory.EXTERNAL_STORAGE_SERVICES ? this.storageAddons() : this.citationAddons() ); protected readonly filteredAddonCards = computed(() => { @@ -82,28 +111,6 @@ export class AddonsComponent { return this.currentAddonsState().filter((card) => card.externalServiceName.includes(searchValue)); }); - protected readonly tabOptions: SelectOption[] = [ - { - label: 'settings.addons.tabs.allAddons', - value: 0, - }, - { - label: 'settings.addons.tabs.connectedAddons', - value: 1, - }, - ]; - - protected readonly categoryOptions: SelectOption[] = [ - { - label: 'settings.addons.categories.additionalService', - value: 'external-storage-services', - }, - { - label: 'settings.addons.categories.citationManager', - value: 'external-citation-services', - }, - ]; - protected onCategoryChange(value: string): void { this.selectedCategory.set(value); } @@ -112,26 +119,32 @@ export class AddonsComponent { effect(() => { // Only proceed if we have the current user if (this.currentUser()) { - this.#store.dispatch(GetAddonsUserReference); + this.store.dispatch(GetAddonsUserReference); + } + }); + + effect(() => { + // Only proceed if we have the current user + if (this.currentUser() && this.userReferenceId()) { + const action = this.currentAction(); + const addons = this.currentAddonsState(); + + if (!addons?.length) { + action(); + } } }); effect(() => { // Only proceed if we have both current user and user reference if (this.currentUser() && this.userReferenceId()) { - this.#loadAddonsIfNeeded(this.userReferenceId()); + this.fetchAllAuthorizedAddons(this.userReferenceId()); } }); } - #loadAddonsIfNeeded(userReferenceId: string): void { - const action = this.currentAction(); - const addons = this.currentAddonsState(); - - if (!addons?.length) { - this.#store.dispatch(action); - this.#store.dispatch(new GetAuthorizedStorageAddons(userReferenceId)); - this.#store.dispatch(new GetAuthorizedCitationAddons(userReferenceId)); - } + private fetchAllAuthorizedAddons(userReferenceId: string): void { + this.actions.getAuthorizedStorageAddons(userReferenceId); + this.actions.getAuthorizedCitationAddons(userReferenceId); } } diff --git a/src/app/features/settings/addons/pages/connect-addon/connect-addon.component.html b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.html similarity index 100% rename from src/app/features/settings/addons/pages/connect-addon/connect-addon.component.html rename to src/app/features/settings/addons/components/connect-addon/connect-addon.component.html diff --git a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.scss b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.scss new file mode 100644 index 000000000..fea06004b --- /dev/null +++ b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.scss @@ -0,0 +1,21 @@ +@use "assets/styles/mixins" as mix; +@use "assets/styles/variables" as var; + +:host { + flex: 1; + @include mix.flex-column; + + .stepper-container { + background-color: var.$white; + flex: 1; + + button { + width: 100%; + } + } + + .folders-list { + border: 1px solid var.$grey-2; + border-radius: 0.57rem; + } +} diff --git a/src/app/features/settings/addons/pages/connect-addon/connect-addon.component.spec.ts b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.spec.ts similarity index 96% rename from src/app/features/settings/addons/pages/connect-addon/connect-addon.component.spec.ts rename to src/app/features/settings/addons/components/connect-addon/connect-addon.component.spec.ts index 32a7ddb8e..941fe02c1 100644 --- a/src/app/features/settings/addons/pages/connect-addon/connect-addon.component.spec.ts +++ b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.spec.ts @@ -50,10 +50,10 @@ describe('ConnectAddonComponent', () => { provideNoopAnimations(), MockProvider(Store, { selectSignal: jest.fn().mockImplementation((selector) => { - if (selector === AddonsSelectors.getAddonUserReference) { + if (selector === AddonsSelectors.getAddonsUserReference) { return () => [{ id: 'test-user-id' }]; } - if (selector === AddonsSelectors.getCreatedOrUpdatedStorageAddon) { + if (selector === AddonsSelectors.getCreatedOrUpdatedAuthorizedAddon) { return () => null; } return () => null; diff --git a/src/app/features/settings/addons/pages/connect-addon/connect-addon.component.ts b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts similarity index 68% rename from src/app/features/settings/addons/pages/connect-addon/connect-addon.component.ts rename to src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts index 7c212953b..a3b37cd7f 100644 --- a/src/app/features/settings/addons/pages/connect-addon/connect-addon.component.ts +++ b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts @@ -1,4 +1,4 @@ -import { Store } from '@ngxs/store'; +import { select, Store } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; @@ -15,12 +15,11 @@ import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } import { Router, RouterLink } from '@angular/router'; import { SubHeaderComponent } from '@osf/shared/components'; -import { Addon, AddonForm, AddonFormControls, AddonRequest, AddonTerm, AuthorizedAddon } from '@shared/models'; +import { ADDON_TERMS as addonTerms } from '@osf/shared/constants'; +import { AddonFormControls, CredentialsFormat } from '@osf/shared/enums'; +import { Addon, AddonForm, AddonRequest, AddonTerm, AuthorizedAddon } from '@shared/models'; import { AddonsSelectors, CreateAuthorizedAddon, UpdateAuthorizedAddon } from '@shared/stores/addons'; -import { ADDON_TERMS as addonTerms } from '../../constants'; -import { CredentialsFormat } from '../../enums'; - @Component({ selector: 'osf-connect-addon', imports: [ @@ -44,26 +43,16 @@ import { CredentialsFormat } from '../../enums'; }) export class ConnectAddonComponent { protected readonly stepper = viewChild(Stepper); - // protected readonly selectedFolders = signal([]); - // protected readonly folders: GoogleDriveFolder[] = [ - // { name: 'folder name example', selected: false }, - // { name: 'folder name example', selected: false }, - // { name: 'folder name example', selected: false }, - // { name: 'folder name example', selected: false }, - // { name: 'folder name example', selected: false }, - // { name: 'folder name example', selected: false }, - // ]; - #router = inject(Router); - #store = inject(Store); - #fb = inject(FormBuilder); - protected readonly credentialsFormat = CredentialsFormat; - protected readonly terms = signal([]); - protected readonly addon = signal(null); - protected readonly addonAuthUrl = signal('/settings/addons'); - protected readonly formControls = AddonFormControls; - protected readonly userReference = this.#store.selectSignal(AddonsSelectors.getAddonUserReference); - protected createdAddon = this.#store.selectSignal(AddonsSelectors.getCreatedOrUpdatedStorageAddon); - protected readonly isConnecting = signal(false); + private router = inject(Router); + private store = inject(Store); + private fb = inject(FormBuilder); + protected credentialsFormat = CredentialsFormat; + protected terms = signal([]); + protected addon = signal(null); + protected addonAuthUrl = signal('/settings/addons'); + protected formControls = AddonFormControls; + protected userReference = select(AddonsSelectors.getAddonsUserReference); + protected createdAddon = select(AddonsSelectors.getCreatedOrUpdatedAuthorizedAddon); protected isAuthorized = computed(() => { //check if the addon is already authorized const addon = this.addon(); @@ -72,7 +61,7 @@ export class ConnectAddonComponent { } return false; }); - protected readonly addonTypeString = computed(() => { + protected addonTypeString = computed(() => { //get the addon type string based on the addon type property const addon = this.addon(); if (addon) { @@ -83,11 +72,12 @@ export class ConnectAddonComponent { return ''; }); protected addonForm: FormGroup; + protected isConnecting = select(AddonsSelectors.getCreatedOrUpdatedStorageAddonSubmitting); constructor() { - const terms = this.#getTerms(); + const terms = this.getTerms(); this.terms.set(terms); - this.addonForm = this.#initializeForm(); + this.addonForm = this.initializeForm(); effect(() => { if (this.isAuthorized()) { @@ -96,20 +86,12 @@ export class ConnectAddonComponent { }); } - // toggleFolderSelection(folder: GoogleDriveFolder): void { - // folder.selected = !folder.selected; - // this.selectedFolders.set( - // this.folders.filter((f) => f.selected).map((f) => f.name), - // ); - // } - - handleConnectStorageAddon() { + handleConnectStorageAddon(): void { if (!this.addon() || !this.addonForm.valid) return; - this.isConnecting.set(true); - const request = this.#generateRequestPayload(); + const request = this.generateRequestPayload(); - this.#store + this.store .dispatch( !this.isAuthorized() ? new CreateAuthorizedAddon(request, this.addonTypeString()) @@ -123,48 +105,44 @@ export class ConnectAddonComponent { window.open(createdAddon.attributes.auth_url, '_blank'); this.stepper()?.value.set(3); } - this.isConnecting.set(false); - }, - error: () => { - this.isConnecting.set(false); }, }); } - #initializeForm(): FormGroup { + private initializeForm(): FormGroup { const addon = this.addon(); if (addon) { const formControls: Partial = { - [AddonFormControls.AccountName]: this.#fb.control(addon.displayName || '', Validators.required), + [AddonFormControls.AccountName]: this.fb.control(addon.displayName || '', Validators.required), }; switch (addon.credentialsFormat) { case CredentialsFormat.ACCESS_SECRET_KEYS: - formControls[AddonFormControls.AccessKey] = this.#fb.control('', Validators.required); - formControls[AddonFormControls.SecretKey] = this.#fb.control('', Validators.required); + formControls[AddonFormControls.AccessKey] = this.fb.control('', Validators.required); + formControls[AddonFormControls.SecretKey] = this.fb.control('', Validators.required); break; case CredentialsFormat.DATAVERSE_API_TOKEN: - formControls[AddonFormControls.HostUrl] = this.#fb.control('', Validators.required); - formControls[AddonFormControls.PersonalAccessToken] = this.#fb.control('', Validators.required); + formControls[AddonFormControls.HostUrl] = this.fb.control('', Validators.required); + formControls[AddonFormControls.PersonalAccessToken] = this.fb.control('', Validators.required); break; case CredentialsFormat.USERNAME_PASSWORD: - formControls[AddonFormControls.HostUrl] = this.#fb.control('', Validators.required); - formControls[AddonFormControls.Username] = this.#fb.control('', Validators.required); - formControls[AddonFormControls.Password] = this.#fb.control('', Validators.required); + formControls[AddonFormControls.HostUrl] = this.fb.control('', Validators.required); + formControls[AddonFormControls.Username] = this.fb.control('', Validators.required); + formControls[AddonFormControls.Password] = this.fb.control('', Validators.required); break; case CredentialsFormat.REPO_TOKEN: - formControls[AddonFormControls.AccessKey] = this.#fb.control('', Validators.required); - formControls[AddonFormControls.SecretKey] = this.#fb.control('', Validators.required); + formControls[AddonFormControls.AccessKey] = this.fb.control('', Validators.required); + formControls[AddonFormControls.SecretKey] = this.fb.control('', Validators.required); break; } - return this.#fb.group(formControls as AddonForm); + return this.fb.group(formControls as AddonForm); } return new FormGroup({} as AddonForm); } - #generateRequestPayload(): AddonRequest { + private generateRequestPayload(): AddonRequest { const formValue = this.addonForm.value; const addon = this.addon()!; const credentials: Record = {}; @@ -208,7 +186,7 @@ export class ConnectAddonComponent { id: this.userReference()[0].id || '', }, }, - ...this.#getServiceRelationship(addon), + ...this.getServiceRelationship(addon), }, type: `authorized-${this.addonTypeString()}-accounts`, }, @@ -217,7 +195,7 @@ export class ConnectAddonComponent { return requestPayload; } - #getServiceRelationship(addon: Addon | AuthorizedAddon) { + private getServiceRelationship(addon: Addon | AuthorizedAddon) { return { [`external_${this.addonTypeString()}_service`]: { data: { @@ -228,10 +206,10 @@ export class ConnectAddonComponent { }; } - #getTerms(): AddonTerm[] { - const addon = this.#router.getCurrentNavigation()?.extras.state?.['addon'] as Addon | AuthorizedAddon; + private getTerms(): AddonTerm[] { + const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as Addon | AuthorizedAddon; if (!addon) { - this.#router.navigate(['/settings/addons']); + this.router.navigate(['/settings/addons']); } this.addon.set(addon); diff --git a/src/app/features/settings/addons/pages/index.ts b/src/app/features/settings/addons/components/index.ts similarity index 100% rename from src/app/features/settings/addons/pages/index.ts rename to src/app/features/settings/addons/components/index.ts diff --git a/src/app/features/settings/addons/constants/index.ts b/src/app/features/settings/addons/constants/index.ts deleted file mode 100644 index bd4b85a29..000000000 --- a/src/app/features/settings/addons/constants/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './addon-terms.const'; diff --git a/src/app/features/settings/addons/enums/index.ts b/src/app/features/settings/addons/enums/index.ts deleted file mode 100644 index d4fd33f4a..000000000 --- a/src/app/features/settings/addons/enums/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './credentials-format.enum'; diff --git a/src/app/features/settings/addons/mappers/index.ts b/src/app/features/settings/addons/mappers/index.ts deleted file mode 100644 index efad3661e..000000000 --- a/src/app/features/settings/addons/mappers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './addon.mapper'; diff --git a/src/app/features/settings/addons/models/index.ts b/src/app/features/settings/addons/models/index.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/app/features/settings/settings.routes.ts b/src/app/features/settings/settings.routes.ts index 72e60ac71..e6936cf8a 100644 --- a/src/app/features/settings/settings.routes.ts +++ b/src/app/features/settings/settings.routes.ts @@ -35,7 +35,9 @@ export const settingsRoutes: Routes = [ { path: 'connect-addon', loadComponent: () => - import('./addons/pages/connect-addon/connect-addon.component').then((mod) => mod.ConnectAddonComponent), + import('@osf/features/settings/addons/components/connect-addon/connect-addon.component').then( + (mod) => mod.ConnectAddonComponent + ), }, ], }, diff --git a/src/app/shared/components/addons/addon-card-list/addon-card-list.component.html b/src/app/shared/components/addons/addon-card-list/addon-card-list.component.html index 80578f42a..9470d7bba 100644 --- a/src/app/shared/components/addons/addon-card-list/addon-card-list.component.html +++ b/src/app/shared/components/addons/addon-card-list/addon-card-list.component.html @@ -5,5 +5,7 @@ } + } @else { +

{{ 'settings.addons.messages.noAddons' | translate }}

} diff --git a/src/app/shared/components/addons/addon-card-list/addon-card-list.component.ts b/src/app/shared/components/addons/addon-card-list/addon-card-list.component.ts index e5757ba68..60357eb65 100644 --- a/src/app/shared/components/addons/addon-card-list/addon-card-list.component.ts +++ b/src/app/shared/components/addons/addon-card-list/addon-card-list.component.ts @@ -1,16 +1,18 @@ +import { TranslatePipe } from '@ngx-translate/core'; + import { Component, input } from '@angular/core'; import { AddonCardComponent } from '@shared/components/addons'; -import { Addon, AuthorizedAddon } from '@shared/models'; +import { Addon, AuthorizedAddon, ConfiguredAddon } from '@shared/models'; @Component({ selector: 'osf-addon-card-list', - imports: [AddonCardComponent], + imports: [AddonCardComponent, TranslatePipe], templateUrl: './addon-card-list.component.html', styleUrl: './addon-card-list.component.scss', }) export class AddonCardListComponent { - cards = input<(Addon | AuthorizedAddon)[]>([]); + cards = input<(Addon | AuthorizedAddon | ConfiguredAddon)[]>([]); cardButtonLabel = input(''); showDangerButton = input(false); } diff --git a/src/app/shared/components/addons/addon-card/addon-card.component.ts b/src/app/shared/components/addons/addon-card/addon-card.component.ts index 2e3bb503d..0ec7d6507 100644 --- a/src/app/shared/components/addons/addon-card/addon-card.component.ts +++ b/src/app/shared/components/addons/addon-card/addon-card.component.ts @@ -10,7 +10,7 @@ import { Component, computed, inject, input, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; -import { Addon, AuthorizedAddon } from '@shared/models'; +import { Addon, AuthorizedAddon, ConfiguredAddon } from '@shared/models'; import { DeleteAuthorizedAddon } from '@shared/stores/addons'; import { IS_XSMALL } from '@shared/utils'; @@ -21,9 +21,9 @@ import { IS_XSMALL } from '@shared/utils'; styleUrl: './addon-card.component.scss', }) export class AddonCardComponent { - #router = inject(Router); - #store = inject(Store); - readonly card = input(null); + private router = inject(Router); + private store = inject(Store); + readonly card = input(null); readonly cardButtonLabel = input(''); readonly showDangerButton = input(false); protected isDialogVisible = signal(false); @@ -40,7 +40,9 @@ export class AddonCardComponent { onConnectAddon(): void { const addon = this.card(); if (addon) { - this.#router.navigate(['/settings/addons/connect-addon'], { + const currentUrl = this.router.url; + const baseUrl = currentUrl.split('/addons')[0]; + this.router.navigate([`${baseUrl}/addons/connect-addon`], { state: { addon }, }); } @@ -60,7 +62,7 @@ export class AddonCardComponent { const addonId = this.card()?.id; if (addonId) { this.isDisabling.set(true); - this.#store.dispatch(new DeleteAuthorizedAddon(addonId, this.addonTypeString())).subscribe({ + this.store.dispatch(new DeleteAuthorizedAddon(addonId, this.addonTypeString())).subscribe({ complete: () => { this.isDisabling.set(false); this.isDialogVisible.set(false); diff --git a/src/app/features/settings/addons/constants/addon-terms.const.ts b/src/app/shared/constants/addon-terms.const.ts similarity index 93% rename from src/app/features/settings/addons/constants/addon-terms.const.ts rename to src/app/shared/constants/addon-terms.const.ts index ac16a705f..f69a2fe31 100644 --- a/src/app/features/settings/addons/constants/addon-terms.const.ts +++ b/src/app/shared/constants/addon-terms.const.ts @@ -1,15 +1,4 @@ -export interface Term { - label: string; - supportedFeature: string; - storage: { - true: string; - false: string; - }; - citation?: { - partial?: string; - false?: string; - }; -} +import { Term } from '@shared/models'; export const ADDON_TERMS: Term[] = [ { diff --git a/src/app/shared/constants/addons-category-options.const.ts b/src/app/shared/constants/addons-category-options.const.ts new file mode 100644 index 000000000..35476bbc9 --- /dev/null +++ b/src/app/shared/constants/addons-category-options.const.ts @@ -0,0 +1,13 @@ +import { AddonCategory } from '@shared/enums/addons-category.enum'; +import { SelectOption } from '@shared/models'; + +export const ADDON_CATEGORY_OPTIONS: SelectOption[] = [ + { + label: 'settings.addons.categories.additionalService', + value: AddonCategory.EXTERNAL_STORAGE_SERVICES, + }, + { + label: 'settings.addons.categories.citationManager', + value: AddonCategory.EXTERNAL_CITATION_SERVICES, + }, +]; diff --git a/src/app/shared/constants/addons-tab-options.const.ts b/src/app/shared/constants/addons-tab-options.const.ts new file mode 100644 index 000000000..088785d21 --- /dev/null +++ b/src/app/shared/constants/addons-tab-options.const.ts @@ -0,0 +1,13 @@ +import { AddonTabValue } from '@shared/enums/addon-tab.enum'; +import { SelectOption } from '@shared/models'; + +export const ADDON_TAB_OPTIONS: SelectOption[] = [ + { + label: 'settings.addons.tabs.allAddons', + value: AddonTabValue.ALL_ADDONS, + }, + { + label: 'settings.addons.tabs.connectedAddons', + value: AddonTabValue.CONNECTED_ADDONS, + }, +]; diff --git a/src/app/shared/constants/index.ts b/src/app/shared/constants/index.ts index 9c28141dd..217000032 100644 --- a/src/app/shared/constants/index.ts +++ b/src/app/shared/constants/index.ts @@ -1,3 +1,6 @@ +export * from './addon-terms.const'; +export * from './addons-category-options.const'; +export * from './addons-tab-options.const'; export * from './input-limits.const'; export * from './input-validation-messages.const'; export * from './remove-nullable.const'; diff --git a/src/app/shared/enums/addon-form-controls.enum.ts b/src/app/shared/enums/addon-form-controls.enum.ts new file mode 100644 index 000000000..1d00e83b2 --- /dev/null +++ b/src/app/shared/enums/addon-form-controls.enum.ts @@ -0,0 +1,9 @@ +export enum AddonFormControls { + AccessKey = 'accessKey', + SecretKey = 'secretKey', + HostUrl = 'hostUrl', + Username = 'username', + Password = 'password', + PersonalAccessToken = 'personalAccessToken', + AccountName = 'accountName', +} diff --git a/src/app/shared/enums/addon-tab.enum.ts b/src/app/shared/enums/addon-tab.enum.ts new file mode 100644 index 000000000..52b92b573 --- /dev/null +++ b/src/app/shared/enums/addon-tab.enum.ts @@ -0,0 +1,4 @@ +export enum AddonTabValue { + ALL_ADDONS = 0, + CONNECTED_ADDONS = 1, +} diff --git a/src/app/shared/enums/addons-category.enum.ts b/src/app/shared/enums/addons-category.enum.ts new file mode 100644 index 000000000..0e6b24b48 --- /dev/null +++ b/src/app/shared/enums/addons-category.enum.ts @@ -0,0 +1,4 @@ +export enum AddonCategory { + EXTERNAL_STORAGE_SERVICES = 'external-storage-services', + EXTERNAL_CITATION_SERVICES = 'external-citation-services', +} diff --git a/src/app/features/settings/addons/enums/credentials-format.enum.ts b/src/app/shared/enums/addons-credentials-format.enum.ts similarity index 100% rename from src/app/features/settings/addons/enums/credentials-format.enum.ts rename to src/app/shared/enums/addons-credentials-format.enum.ts diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts index 5b89b32f6..810faf37f 100644 --- a/src/app/shared/enums/index.ts +++ b/src/app/shared/enums/index.ts @@ -1,9 +1,15 @@ +export * from './addon-form-controls.enum'; +export * from './addon-tab.enum'; +export * from './addons-category.enum'; +export * from './addons-credentials-format.enum'; export * from './breakpoint-queries.enum'; export * from './create-component-form-controls.enum'; export * from './create-project-form-controls.enum'; export * from './filter-type.enum'; +export * from './profile-addons-stepper.enum'; export * from './resource-tab.enum'; export * from './resource-type.enum'; +export * from './settings-addons-stepper.enum'; export * from './share-indexing.enum'; export * from './sort-order.enum'; export * from './subscriptions'; diff --git a/src/app/shared/enums/profile-addons-stepper.enum.ts b/src/app/shared/enums/profile-addons-stepper.enum.ts new file mode 100644 index 000000000..367d4065e --- /dev/null +++ b/src/app/shared/enums/profile-addons-stepper.enum.ts @@ -0,0 +1,7 @@ +export enum ProjectAddonsStepperValue { + TERMS = 1, + CHOOSE_CONNECTION = 2, + CHOOSE_ACCOUNT = 3, + SETUP_NEW_ACCOUNT = 4, + AUTH = 5, +} diff --git a/src/app/features/settings/addons/mappers/addon.mapper.ts b/src/app/shared/mappers/addon.mapper.ts similarity index 80% rename from src/app/features/settings/addons/mappers/addon.mapper.ts rename to src/app/shared/mappers/addon.mapper.ts index d5cdab28f..181bbd176 100644 --- a/src/app/features/settings/addons/mappers/addon.mapper.ts +++ b/src/app/shared/mappers/addon.mapper.ts @@ -3,6 +3,8 @@ import { AddonGetResponse, AuthorizedAddon, AuthorizedAddonGetResponse, + ConfiguredAddon, + ConfiguredAddonGetResponse, IncludedAddonData, } from '@shared/models'; @@ -61,4 +63,17 @@ export class AddonMapper { providerName: displayName, }; } + + static fromConfiguredAddonResponse(response: ConfiguredAddonGetResponse): ConfiguredAddon { + return { + type: response.type, + id: response.id, + displayName: response.attributes.display_name, + externalServiceName: response.attributes.external_service_name, + rootFolder: response.attributes.root_folder, + connectedCapabilities: response.attributes.connected_capabilities, + connectedOperationNames: response.attributes.connected_operation_names, + currentUserIsOwner: response.attributes.current_user_is_owner, + }; + } } diff --git a/src/app/shared/mappers/index.ts b/src/app/shared/mappers/index.ts index 725a8fcff..82df7cbb1 100644 --- a/src/app/shared/mappers/index.ts +++ b/src/app/shared/mappers/index.ts @@ -1,2 +1,3 @@ +export * from './addon.mapper'; export * from './filters'; export * from './resource-card'; diff --git a/src/app/shared/models/addons/addon-form.model.ts b/src/app/shared/models/addons/addon-form.model.ts index a0fc82b06..7b0c37cc7 100644 --- a/src/app/shared/models/addons/addon-form.model.ts +++ b/src/app/shared/models/addons/addon-form.model.ts @@ -1,14 +1,6 @@ import { FormControl } from '@angular/forms'; -export enum AddonFormControls { - AccessKey = 'accessKey', - SecretKey = 'secretKey', - HostUrl = 'hostUrl', - Username = 'username', - Password = 'password', - PersonalAccessToken = 'personalAccessToken', - AccountName = 'accountName', -} +import { AddonFormControls } from '@shared/enums'; export interface AddonForm { [AddonFormControls.AccessKey]?: FormControl; diff --git a/src/app/shared/models/addons/addons.model.ts b/src/app/shared/models/addons/addons.models.ts similarity index 83% rename from src/app/shared/models/addons/addons.model.ts rename to src/app/shared/models/addons/addons.models.ts index 6fb69262b..1e3c731e7 100644 --- a/src/app/shared/models/addons/addons.model.ts +++ b/src/app/shared/models/addons/addons.models.ts @@ -53,6 +53,19 @@ export interface AuthorizedAddonGetResponse { }; } +export interface ConfiguredAddonGetResponse { + type: string; + id: string; + attributes: { + display_name: string; + external_service_name: string; + root_folder: string; + connected_capabilities: string[]; + connected_operation_names: string[]; + current_user_is_owner: boolean; + }; +} + export interface Addon { type: string; id: string; @@ -82,6 +95,17 @@ export interface AuthorizedAddon { credentialsFormat: string; } +export interface ConfiguredAddon { + type: string; + id: string; + displayName: string; + externalServiceName: string; + rootFolder: string; + connectedCapabilities: string[]; + connectedOperationNames: string[]; + currentUserIsOwner: boolean; +} + export interface IncludedAddonData { type: string; id: string; @@ -105,6 +129,14 @@ export interface UserReference { }; } +export interface ResourceReference { + type: string; + id: string; + attributes: { + resource_uri: string; + }; +} + export interface AddonRequest { data: { id?: string; diff --git a/src/app/shared/models/addons/index.ts b/src/app/shared/models/addons/index.ts index 1da58de17..1b34b1d47 100644 --- a/src/app/shared/models/addons/index.ts +++ b/src/app/shared/models/addons/index.ts @@ -1,3 +1,4 @@ export * from './addon-form.model'; export * from './addon-terms.model'; -export * from './addons.model'; +export * from './addons.models'; +export * from './term.model'; diff --git a/src/app/shared/models/addons/term.model.ts b/src/app/shared/models/addons/term.model.ts new file mode 100644 index 000000000..053988852 --- /dev/null +++ b/src/app/shared/models/addons/term.model.ts @@ -0,0 +1,12 @@ +export interface Term { + label: string; + supportedFeature: string; + storage: { + true: string; + false: string; + }; + citation?: { + partial?: string; + false?: string; + }; +} diff --git a/src/app/shared/services/addons.service.ts b/src/app/shared/services/addons.service.ts index 6eae8f661..e3ad4c917 100644 --- a/src/app/shared/services/addons.service.ts +++ b/src/app/shared/services/addons.service.ts @@ -7,7 +7,7 @@ import { inject, Injectable } from '@angular/core'; import { JsonApiResponse } from '@core/models'; import { JsonApiService } from '@core/services'; import { UserSelectors } from '@core/store/user'; -import { AddonMapper } from '@osf/features/settings/addons/mappers'; +import { AddonMapper } from '@osf/shared/mappers'; import { Addon, AddonGetResponse, @@ -15,7 +15,10 @@ import { AddonResponse, AuthorizedAddon, AuthorizedAddonGetResponse, + ConfiguredAddon, + ConfiguredAddonGetResponse, IncludedAddonData, + ResourceReference, UserReference, } from '@shared/models'; @@ -53,6 +56,17 @@ export class AddonsService { .pipe(map((response) => response.data)); } + getAddonsResourceReference(resourceId: string): Observable { + const resourceUri = `https://staging4.osf.io/${resourceId}`; + const params = { + 'filter[resource_uri]': resourceUri, + }; + + return this.#jsonApiService + .get>(environment.addonsApiUrl + '/resource-references/', params) + .pipe(map((response) => response.data)); + } + getAuthorizedAddons(addonType: string, referenceId: string): Observable { const params = { [`fields[external-${addonType}-services]`]: 'external_service_name', @@ -68,6 +82,18 @@ export class AddonsService { ); } + getConfiguredAddons(addonType: string, referenceId: string): Observable { + return this.#jsonApiService + .get< + JsonApiResponse + >(`${environment.addonsApiUrl}/resource-references/${referenceId}/configured_${addonType}_accounts/`) + .pipe( + map((response) => { + return response.data.map((item) => AddonMapper.fromConfiguredAddonResponse(item)); + }) + ); + } + createAuthorizedAddon(addonRequestPayload: AddonRequest, addonType: string): Observable { return this.#jsonApiService.post( `${environment.addonsApiUrl}/authorized-${addonType}-accounts/`, diff --git a/src/app/shared/stores/addons/addons.actions.ts b/src/app/shared/stores/addons/addons.actions.ts index a3f8053d6..237a61cad 100644 --- a/src/app/shared/stores/addons/addons.actions.ts +++ b/src/app/shared/stores/addons/addons.actions.ts @@ -20,6 +20,18 @@ export class GetAuthorizedCitationAddons { constructor(public referenceId: string) {} } +export class GetConfiguredStorageAddons { + static readonly type = '[Addons] Get Configured Storage Addons'; + + constructor(public referenceId: string) {} +} + +export class GetConfiguredCitationAddons { + static readonly type = '[Addons] Get Configured Citation Addons'; + + constructor(public referenceId: string) {} +} + export class CreateAuthorizedAddon { static readonly type = '[Addons] Create Storage Addon'; @@ -43,6 +55,12 @@ export class GetAddonsUserReference { static readonly type = '[Addons] Get Addons User Reference'; } +export class GetAddonsResourceReference { + static readonly type = '[Addons] Get Addons Resource Reference'; + + constructor(public resourceId: string) {} +} + export class DeleteAuthorizedAddon { static readonly type = '[Addons] Delete Authorized Addon'; diff --git a/src/app/shared/stores/addons/addons.models.ts b/src/app/shared/stores/addons/addons.models.ts index 426488b2d..7f2fa30ed 100644 --- a/src/app/shared/stores/addons/addons.models.ts +++ b/src/app/shared/stores/addons/addons.models.ts @@ -1,10 +1,21 @@ -import { Addon, AddonResponse, AuthorizedAddon, UserReference } from '@shared/models'; +import { + Addon, + AddonResponse, + AuthorizedAddon, + ConfiguredAddon, + ResourceReference, + UserReference, +} from '@shared/models'; +import { AsyncStateModel } from '@shared/models/store'; export interface AddonsStateModel { - storageAddons: Addon[]; - citationAddons: Addon[]; - authorizedStorageAddons: AuthorizedAddon[]; - authorizedCitationAddons: AuthorizedAddon[]; - addonsUserReference: UserReference[]; - createdUpdatedAuthorizedAddon: AddonResponse | null; + storageAddons: AsyncStateModel; + citationAddons: AsyncStateModel; + authorizedStorageAddons: AsyncStateModel; + authorizedCitationAddons: AsyncStateModel; + configuredStorageAddons: AsyncStateModel; + configuredCitationAddons: AsyncStateModel; + addonsUserReference: AsyncStateModel; + addonsResourceReference: AsyncStateModel; + createdUpdatedAuthorizedAddon: AsyncStateModel; } diff --git a/src/app/shared/stores/addons/addons.selectors.ts b/src/app/shared/stores/addons/addons.selectors.ts index a76e5740d..5a911880e 100644 --- a/src/app/shared/stores/addons/addons.selectors.ts +++ b/src/app/shared/stores/addons/addons.selectors.ts @@ -1,6 +1,13 @@ import { Selector } from '@ngxs/store'; -import { Addon } from '@shared/models'; +import { + Addon, + AddonResponse, + AuthorizedAddon, + ConfiguredAddon, + ResourceReference, + UserReference, +} from '@shared/models'; import { AddonsStateModel } from './addons.models'; import { AddonsState } from './addons.state'; @@ -8,31 +15,91 @@ import { AddonsState } from './addons.state'; export class AddonsSelectors { @Selector([AddonsState]) static getStorageAddons(state: AddonsStateModel): Addon[] { - return state.storageAddons; + return state.storageAddons.data; + } + + @Selector([AddonsState]) + static getStorageAddonsLoading(state: AddonsStateModel): boolean { + return state.storageAddons.isLoading; } @Selector([AddonsState]) static getCitationAddons(state: AddonsStateModel): Addon[] { - return state.citationAddons; + return state.citationAddons.data; + } + + @Selector([AddonsState]) + static getCitationAddonsLoading(state: AddonsStateModel): boolean { + return state.citationAddons.isLoading; + } + + @Selector([AddonsState]) + static getAuthorizedStorageAddons(state: AddonsStateModel): AuthorizedAddon[] { + return state.authorizedStorageAddons.data; + } + + @Selector([AddonsState]) + static getAuthorizedStorageAddonsLoading(state: AddonsStateModel): boolean { + return state.authorizedStorageAddons.isLoading; + } + + @Selector([AddonsState]) + static getAuthorizedCitationAddons(state: AddonsStateModel): AuthorizedAddon[] { + return state.authorizedCitationAddons.data; + } + + @Selector([AddonsState]) + static getAuthorizedCitationAddonsLoading(state: AddonsStateModel): boolean { + return state.authorizedCitationAddons.isLoading; + } + + @Selector([AddonsState]) + static getConfiguredStorageAddons(state: AddonsStateModel): ConfiguredAddon[] { + return state.configuredStorageAddons.data; + } + + @Selector([AddonsState]) + static getConfiguredStorageAddonsLoading(state: AddonsStateModel): boolean { + return state.configuredStorageAddons.isLoading; + } + + @Selector([AddonsState]) + static getConfiguredCitationAddons(state: AddonsStateModel): ConfiguredAddon[] { + return state.configuredCitationAddons.data; + } + + @Selector([AddonsState]) + static getConfiguredCitationAddonsLoading(state: AddonsStateModel): boolean { + return state.configuredCitationAddons.isLoading; + } + + @Selector([AddonsState]) + static getAddonsUserReference(state: AddonsStateModel): UserReference[] { + return state.addonsUserReference.data; + } + + @Selector([AddonsState]) + static getAddonsUserReferenceLoading(state: AddonsStateModel): boolean { + return state.addonsUserReference.isLoading; } @Selector([AddonsState]) - static getAuthorizedStorageAddons(state: AddonsStateModel) { - return state.authorizedStorageAddons; + static getAddonsResourceReference(state: AddonsStateModel): ResourceReference[] { + return state.addonsResourceReference.data; } @Selector([AddonsState]) - static getAuthorizedCitationAddons(state: AddonsStateModel) { - return state.authorizedCitationAddons; + static getAddonsResourceReferenceLoading(state: AddonsStateModel): boolean { + return state.addonsResourceReference.isLoading; } @Selector([AddonsState]) - static getAddonUserReference(state: AddonsStateModel) { - return state.addonsUserReference; + static getCreatedOrUpdatedAuthorizedAddon(state: AddonsStateModel): AddonResponse | null { + return state.createdUpdatedAuthorizedAddon.data; } @Selector([AddonsState]) - static getCreatedOrUpdatedStorageAddon(state: AddonsStateModel) { - return state.createdUpdatedAuthorizedAddon; + static getCreatedOrUpdatedStorageAddonSubmitting(state: AddonsStateModel): boolean { + return state.createdUpdatedAuthorizedAddon.isSubmitting || false; } } diff --git a/src/app/shared/stores/addons/addons.state.ts b/src/app/shared/stores/addons/addons.state.ts index 073d311a6..d29189383 100644 --- a/src/app/shared/stores/addons/addons.state.ts +++ b/src/app/shared/stores/addons/addons.state.ts @@ -1,34 +1,80 @@ import { Action, State, StateContext } from '@ngxs/store'; -import { Observable, switchMap, tap } from 'rxjs'; +import { catchError, switchMap, tap, throwError } from 'rxjs'; import { inject, Injectable } from '@angular/core'; -import { AddonResponse } from '@shared/models'; import { AddonsService } from '@shared/services'; import { CreateAuthorizedAddon, DeleteAuthorizedAddon, + GetAddonsResourceReference, GetAddonsUserReference, GetAuthorizedCitationAddons, GetAuthorizedStorageAddons, GetCitationAddons, + GetConfiguredCitationAddons, + GetConfiguredStorageAddons, GetStorageAddons, UpdateAuthorizedAddon, } from './addons.actions'; import { AddonsStateModel } from './addons.models'; +const ADDONS_DEFAULTS: AddonsStateModel = { + storageAddons: { + data: [], + isLoading: false, + error: null, + }, + citationAddons: { + data: [], + isLoading: false, + error: null, + }, + authorizedStorageAddons: { + data: [], + isLoading: false, + isSubmitting: false, + error: null, + }, + authorizedCitationAddons: { + data: [], + isLoading: false, + isSubmitting: false, + error: null, + }, + configuredStorageAddons: { + data: [], + isLoading: false, + error: null, + }, + configuredCitationAddons: { + data: [], + isLoading: false, + error: null, + }, + addonsUserReference: { + data: [], + isLoading: false, + error: null, + }, + addonsResourceReference: { + data: [], + isLoading: false, + error: null, + }, + createdUpdatedAuthorizedAddon: { + data: null, + isLoading: false, + isSubmitting: false, + error: null, + }, +}; + @State({ name: 'addons', - defaults: { - storageAddons: [], - citationAddons: [], - authorizedStorageAddons: [], - authorizedCitationAddons: [], - addonsUserReference: [], - createdUpdatedAuthorizedAddon: null, - }, + defaults: ADDONS_DEFAULTS, }) @Injectable() export class AddonsState { @@ -36,84 +82,297 @@ export class AddonsState { @Action(GetStorageAddons) getStorageAddons(ctx: StateContext) { + const state = ctx.getState(); + ctx.patchState({ + storageAddons: { + ...state.storageAddons, + isLoading: true, + }, + }); + return this.addonsService.getAddons('storage').pipe( tap((addons) => { - ctx.patchState({ storageAddons: addons }); - }) + ctx.patchState({ + storageAddons: { + data: addons, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'storageAddons', error)) ); } @Action(GetCitationAddons) getCitationAddons(ctx: StateContext) { + const state = ctx.getState(); + ctx.patchState({ + citationAddons: { + ...state.citationAddons, + isLoading: true, + }, + }); + return this.addonsService.getAddons('citation').pipe( tap((addons) => { - ctx.patchState({ citationAddons: addons }); - }) + ctx.patchState({ + citationAddons: { + data: addons, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'citationAddons', error)) ); } @Action(GetAuthorizedStorageAddons) getAuthorizedStorageAddons(ctx: StateContext, action: GetAuthorizedStorageAddons) { + const state = ctx.getState(); + ctx.patchState({ + authorizedStorageAddons: { + ...state.authorizedStorageAddons, + isLoading: true, + }, + }); + return this.addonsService.getAuthorizedAddons('storage', action.referenceId).pipe( tap((addons) => { - ctx.patchState({ authorizedStorageAddons: addons }); - }) + ctx.patchState({ + authorizedStorageAddons: { + data: addons, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'authorizedStorageAddons', error)) ); } @Action(GetAuthorizedCitationAddons) getAuthorizedCitationAddons(ctx: StateContext, action: GetAuthorizedCitationAddons) { + const state = ctx.getState(); + ctx.patchState({ + authorizedCitationAddons: { + ...state.authorizedCitationAddons, + isLoading: true, + }, + }); + return this.addonsService.getAuthorizedAddons('citation', action.referenceId).pipe( tap((addons) => { - ctx.patchState({ authorizedCitationAddons: addons }); - }) + ctx.patchState({ + authorizedCitationAddons: { + data: addons, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'authorizedCitationAddons', error)) + ); + } + + @Action(GetConfiguredStorageAddons) + getConfiguredStorageAddons(ctx: StateContext, action: GetConfiguredStorageAddons) { + const state = ctx.getState(); + ctx.patchState({ + configuredStorageAddons: { + ...state.configuredStorageAddons, + isLoading: true, + }, + }); + + return this.addonsService.getConfiguredAddons('storage', action.referenceId).pipe( + tap((addons) => { + ctx.patchState({ + configuredStorageAddons: { + data: addons, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'configuredStorageAddons', error)) + ); + } + + @Action(GetConfiguredCitationAddons) + getConfiguredCitationAddons(ctx: StateContext, action: GetConfiguredCitationAddons) { + const state = ctx.getState(); + ctx.patchState({ + configuredCitationAddons: { + ...state.configuredCitationAddons, + isLoading: true, + }, + }); + + return this.addonsService.getConfiguredAddons('citation', action.referenceId).pipe( + tap((addons) => { + ctx.patchState({ + configuredCitationAddons: { + data: addons, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'configuredCitationAddons', error)) ); } @Action(CreateAuthorizedAddon) - createAuthorizedAddon(ctx: StateContext, action: CreateAuthorizedAddon): Observable { + createAuthorizedAddon(ctx: StateContext, action: CreateAuthorizedAddon) { + const state = ctx.getState(); + ctx.patchState({ + createdUpdatedAuthorizedAddon: { + ...state.createdUpdatedAuthorizedAddon, + isSubmitting: true, + }, + }); + return this.addonsService.createAuthorizedAddon(action.payload, action.addonType).pipe( tap((addon) => { - ctx.patchState({ createdUpdatedAuthorizedAddon: addon }); - const referenceId = ctx.getState().addonsUserReference[0].id; - return action.addonType === 'storage' - ? ctx.dispatch(new GetAuthorizedStorageAddons(referenceId)) - : ctx.dispatch(new GetAuthorizedCitationAddons(referenceId)); - }) + ctx.patchState({ + createdUpdatedAuthorizedAddon: { + data: addon, + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + const referenceId = state.addonsUserReference.data[0]?.id; + if (referenceId) { + ctx.dispatch( + action.addonType === 'storage' + ? new GetAuthorizedStorageAddons(referenceId) + : new GetAuthorizedCitationAddons(referenceId) + ); + } + }), + catchError((error) => this.handleError(ctx, 'createdUpdatedAuthorizedAddon', error)) ); } @Action(UpdateAuthorizedAddon) - updateAuthorizedAddon(ctx: StateContext, action: UpdateAuthorizedAddon): Observable { + updateAuthorizedAddon(ctx: StateContext, action: UpdateAuthorizedAddon) { + const state = ctx.getState(); + ctx.patchState({ + createdUpdatedAuthorizedAddon: { + ...state.createdUpdatedAuthorizedAddon, + isSubmitting: true, + }, + }); + return this.addonsService.updateAuthorizedAddon(action.payload, action.addonType, action.addonId).pipe( tap((addon) => { - ctx.patchState({ createdUpdatedAuthorizedAddon: addon }); - const referenceId = ctx.getState().addonsUserReference[0].id; - return action.addonType === 'storage' - ? ctx.dispatch(new GetAuthorizedStorageAddons(referenceId)) - : ctx.dispatch(new GetAuthorizedCitationAddons(referenceId)); - }) + ctx.patchState({ + createdUpdatedAuthorizedAddon: { + data: addon, + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + const referenceId = state.addonsUserReference.data[0]?.id; + if (referenceId) { + ctx.dispatch( + action.addonType === 'storage' + ? new GetAuthorizedStorageAddons(referenceId) + : new GetAuthorizedCitationAddons(referenceId) + ); + } + }), + catchError((error) => this.handleError(ctx, 'createdUpdatedAuthorizedAddon', error)) ); } @Action(GetAddonsUserReference) getAddonsUserReference(ctx: StateContext) { + const state = ctx.getState(); + ctx.patchState({ + addonsUserReference: { + ...state.addonsUserReference, + isLoading: true, + }, + }); + return this.addonsService.getAddonsUserReference().pipe( tap((userReference) => { - ctx.patchState({ addonsUserReference: userReference }); - }) + ctx.patchState({ + addonsUserReference: { + data: userReference, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'addonsUserReference', error)) + ); + } + + @Action(GetAddonsResourceReference) + getAddonsResourceReference(ctx: StateContext, action: GetAddonsResourceReference) { + const state = ctx.getState(); + ctx.patchState({ + addonsResourceReference: { + ...state.addonsResourceReference, + isLoading: true, + }, + }); + + return this.addonsService.getAddonsResourceReference(action.resourceId).pipe( + tap((resourceReference) => { + ctx.patchState({ + addonsResourceReference: { + data: resourceReference, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => this.handleError(ctx, 'addonsResourceReference', error)) ); } @Action(DeleteAuthorizedAddon) deleteAuthorizedAddon(ctx: StateContext, action: DeleteAuthorizedAddon) { + const state = ctx.getState(); + const stateKey = action.addonType === 'storage' ? 'authorizedStorageAddons' : 'authorizedCitationAddons'; + ctx.patchState({ + [stateKey]: { + ...state[stateKey], + isSubmitting: true, + }, + }); + return this.addonsService.deleteAuthorizedAddon(action.payload, action.addonType).pipe( switchMap(() => { - const referenceId = ctx.getState().addonsUserReference[0].id; - return action.addonType === 'storage' - ? ctx.dispatch(new GetAuthorizedStorageAddons(referenceId)) - : ctx.dispatch(new GetAuthorizedCitationAddons(referenceId)); - }) + const referenceId = state.addonsUserReference.data[0]?.id; + if (referenceId) { + return action.addonType === 'storage' + ? ctx.dispatch(new GetAuthorizedStorageAddons(referenceId)) + : ctx.dispatch(new GetAuthorizedCitationAddons(referenceId)); + } + return []; + }), + catchError((error) => this.handleError(ctx, stateKey, error)) ); } + + private handleError(ctx: StateContext, section: keyof AddonsStateModel, error: Error) { + const state = ctx.getState(); + ctx.patchState({ + [section]: { + ...state[section], + isLoading: false, + isSubmitting: false, + error: error.message, + }, + }); + return throwError(() => error); + } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index faf3cca31..7353534f4 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -927,10 +927,13 @@ "function": "Function", "status": "Status" }, + "configure": "Configure ", "termsDescription": "• This add-on connects your OSF project to an external service. Use of this service is bound by its terms and conditions. The OSF is not responsible for the service or for your use thereof.", "storageDescription": "• This add-on allows you to store files using an external service. Files added to this add-on are not stored within the OSF.", "reconnectAccount": "Reconnect Account", "setupNewAccount": "Setup new account", + "chooseExistingAccount": "Choose existing account", + "loginToOrSelectAccount": "Login to {{addonName}} or select an account", "startOauth": "Start OAuth", "oauthDescription": "Complete the OAuth process in the new window before returning to this page. If you do not see a new window, please click the Start OAuth link below." }, @@ -950,12 +953,15 @@ "buttons": { "cancel": "Cancel", "next": "Next", + "acceptingTerms": "Accepting...", "back": "Back", "connect": "Connect", "reconnect": "Reconnect", "authorize": "Authorize", "disable": "Disable", - "startOauth": "Start OAuth" + "startOauth": "Start OAuth", + "newAccount": "Setup New Account", + "existingAccount": "Choose Existing Account" } }, "messages": { @@ -968,7 +974,8 @@ "deleteConfirmation": { "title": "Disable Account?", "message": "Are you sure you want to disable this account? All projects connected to this account will be affected." - } + }, + "noAddons": "No results found." } }, "profileSettings": { diff --git a/src/assets/styles/overrides/button.scss b/src/assets/styles/overrides/button.scss index 9b4ab9313..0e3910e13 100644 --- a/src/assets/styles/overrides/button.scss +++ b/src/assets/styles/overrides/button.scss @@ -201,6 +201,8 @@ } .p-button-link { + --p-button-link-color: var(--white); + a { color: var.$white; text-decoration: none; From 2ccd2584974a97bc6fa2cd8fdfc34c7f5ce82273 Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Mon, 16 Jun 2025 22:23:57 +0300 Subject: [PATCH 05/18] feat(profile-addons-api): added addon configuration functionality --- src/app/app.routes.ts | 11 +- .../my-projects/mappers/my-projects.mapper.ts | 4 +- .../models/create-project.model.ts | 2 +- .../my-projects/models/my-projects.models.ts | 6 +- .../services/my-projects.service.ts | 28 +- .../project/addons/addons.component.html | 9 +- .../project/addons/addons.component.ts | 45 ++- .../configure-addon.component.html | 182 ++++++++++++ .../configure-addon.component.scss | 5 + .../configure-addon.component.spec.ts | 22 ++ .../configure-addon.component.ts | 277 ++++++++++++++++++ ...rm-account-connection-modal.component.html | 17 ++ ...rm-account-connection-modal.component.scss | 0 ...account-connection-modal.component.spec.ts | 22 ++ ...firm-account-connection-modal.component.ts | 60 ++++ .../connect-configured-addon.component.html} | 108 +++++-- .../connect-configured-addon.component.scss} | 15 +- ...onnect-configured-addon.component.spec.ts} | 15 +- .../connect-configured-addon.component.ts} | 179 ++++++++--- .../disconnect-addon-modal.component.html | 26 ++ .../disconnect-addon-modal.component.scss | 0 .../disconnect-addon-modal.component.spec.ts | 22 ++ .../disconnect-addon-modal.component.ts | 37 +++ .../project/addons/components/index.ts | 5 +- .../features/project/addons/enums/index.ts | 1 + .../addons/enums/operation-names.enum.ts | 5 + .../mappers/project-overview.mapper.ts | 11 +- .../models/project-overview.models.ts | 8 +- .../services/project-overview.service.ts | 13 +- .../settings/addons/addons.component.html | 20 +- .../settings/addons/addons.component.ts | 32 +- .../connect-addon.component.spec.ts | 5 +- .../connect-addon/connect-addon.component.ts | 6 +- .../settings/tokens/mappers/token.mapper.ts | 8 +- .../settings/tokens/models/tokens.model.ts | 6 +- .../tokens/services/tokens.service.ts | 20 +- .../settings/tokens/tokens.component.ts | 7 +- .../addon-card/addon-card.component.html | 2 +- .../addons/addon-card/addon-card.component.ts | 23 +- src/app/shared/enums/index.ts | 1 - .../enums/profile-addons-stepper.enum.ts | 5 +- src/app/shared/mappers/addon.mapper.ts | 54 +++- src/app/shared/models/addons/addons.models.ts | 111 ++++++- src/app/shared/models/addons/index.ts | 1 + .../addons/operation-invocation.models.ts | 77 +++++ src/app/shared/services/addons.service.ts | 92 ++++-- .../shared/stores/addons/addons.actions.ts | 54 +++- src/app/shared/stores/addons/addons.models.ts | 17 +- .../shared/stores/addons/addons.selectors.ts | 49 +++- src/app/shared/stores/addons/addons.state.ts | 188 +++++++++++- src/assets/i18n/en.json | 81 +---- src/assets/styles/components/addons.scss | 46 +++ src/assets/styles/styles.scss | 1 + 53 files changed, 1756 insertions(+), 285 deletions(-) create mode 100644 src/app/features/project/addons/components/configure-addon/configure-addon.component.html create mode 100644 src/app/features/project/addons/components/configure-addon/configure-addon.component.scss create mode 100644 src/app/features/project/addons/components/configure-addon/configure-addon.component.spec.ts create mode 100644 src/app/features/project/addons/components/configure-addon/configure-addon.component.ts create mode 100644 src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.html create mode 100644 src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.scss create mode 100644 src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.spec.ts create mode 100644 src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.ts rename src/app/features/project/addons/components/{connect-configure-addon/connect-configure-addon.component.html => connect-configured-addon/connect-configured-addon.component.html} (76%) rename src/app/features/project/addons/components/{connect-configure-addon/connect-configure-addon.component.scss => connect-configured-addon/connect-configured-addon.component.scss} (51%) rename src/app/features/project/addons/components/{connect-configure-addon/connect-configure-addon.component.spec.ts => connect-configured-addon/connect-configured-addon.component.spec.ts} (83%) rename src/app/features/project/addons/components/{connect-configure-addon/connect-configure-addon.component.ts => connect-configured-addon/connect-configured-addon.component.ts} (66%) create mode 100644 src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.html create mode 100644 src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.scss create mode 100644 src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.spec.ts create mode 100644 src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.ts create mode 100644 src/app/features/project/addons/enums/index.ts create mode 100644 src/app/features/project/addons/enums/operation-names.enum.ts create mode 100644 src/app/shared/models/addons/operation-invocation.models.ts create mode 100644 src/assets/styles/components/addons.scss diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index f8ecec4fb..f09fd838f 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -175,8 +175,15 @@ export const routes: Routes = [ path: 'connect-addon', loadComponent: () => import( - './features/project/addons/components/connect-configure-addon/connect-configure-addon.component' - ).then((mod) => mod.ConnectConfigureAddonComponent), + '@osf/features/project/addons/components/connect-configured-addon/connect-configured-addon.component' + ).then((mod) => mod.ConnectConfiguredAddonComponent), + }, + { + path: 'configure-addon', + loadComponent: () => + import('@osf/features/project/addons/components/configure-addon/configure-addon.component').then( + (mod) => mod.ConfigureAddonComponent + ), }, ], }, diff --git a/src/app/features/my-projects/mappers/my-projects.mapper.ts b/src/app/features/my-projects/mappers/my-projects.mapper.ts index 79128fbf3..c2579892a 100644 --- a/src/app/features/my-projects/mappers/my-projects.mapper.ts +++ b/src/app/features/my-projects/mappers/my-projects.mapper.ts @@ -1,7 +1,7 @@ -import { MyProjectsItem, MyProjectsItemGetResponse } from '../models'; +import { MyProjectsItem, MyProjectsItemGetResponseJsonApi } from '../models'; export class MyProjectsMapper { - static fromResponse(response: MyProjectsItemGetResponse): MyProjectsItem { + static fromResponse(response: MyProjectsItemGetResponseJsonApi): MyProjectsItem { return { id: response.id, type: response.type, diff --git a/src/app/features/my-projects/models/create-project.model.ts b/src/app/features/my-projects/models/create-project.model.ts index ea95d68ef..71dc48c8e 100644 --- a/src/app/features/my-projects/models/create-project.model.ts +++ b/src/app/features/my-projects/models/create-project.model.ts @@ -1,4 +1,4 @@ -export interface CreateProjectPayload { +export interface CreateProjectPayloadJsoApi { data: { type: 'nodes'; attributes: { diff --git a/src/app/features/my-projects/models/my-projects.models.ts b/src/app/features/my-projects/models/my-projects.models.ts index 0a0a9ab19..215e0c4fc 100644 --- a/src/app/features/my-projects/models/my-projects.models.ts +++ b/src/app/features/my-projects/models/my-projects.models.ts @@ -1,6 +1,6 @@ import { JsonApiResponse } from '@osf/core/models'; -export interface MyProjectsItemGetResponse { +export interface MyProjectsItemGetResponseJsonApi { id: string; type: string; attributes: { @@ -50,7 +50,7 @@ export interface MyProjectsItem { contributors: MyProjectsContributor[]; } -export interface MyProjectsItemResponse { +export interface MyProjectsItemResponseJsonApi { data: MyProjectsItem[]; links: { meta: { @@ -60,7 +60,7 @@ export interface MyProjectsItemResponse { }; } -export interface MyProjectsJsonApiResponse extends JsonApiResponse { +export interface MyProjectsResponseJsonApi extends JsonApiResponse { links: { meta: { total: number; diff --git a/src/app/features/my-projects/services/my-projects.service.ts b/src/app/features/my-projects/services/my-projects.service.ts index 8d0bc708c..3ad8cf027 100644 --- a/src/app/features/my-projects/services/my-projects.service.ts +++ b/src/app/features/my-projects/services/my-projects.service.ts @@ -10,12 +10,12 @@ import { NodeResponseModel, UpdateNodeRequestModel } from '@shared/models'; import { MyProjectsMapper } from '../mappers'; import { - CreateProjectPayload, + CreateProjectPayloadJsoApi, EndpointType, MyProjectsItem, - MyProjectsItemGetResponse, - MyProjectsItemResponse, - MyProjectsJsonApiResponse, + MyProjectsItemGetResponseJsonApi, + MyProjectsItemResponseJsonApi, + MyProjectsResponseJsonApi, MyProjectsSearchFilters, } from '../models'; @@ -38,7 +38,7 @@ export class MyProjectsService { filters?: MyProjectsSearchFilters, pageNumber?: number, pageSize?: number - ): Observable { + ): Observable { const params: Record = { 'embed[]': ['bibliographic_contributors'], [`fields[${endpoint}]`]: 'title,date_modified,public,bibliographic_contributors', @@ -69,9 +69,9 @@ export class MyProjectsService { ? environment.apiUrl + '/' + endpoint : environment.apiUrl + '/users/me/' + endpoint; - return this.jsonApiService.get(url, params).pipe( - map((response: MyProjectsJsonApiResponse) => ({ - data: response.data.map((item: MyProjectsItemGetResponse) => MyProjectsMapper.fromResponse(item)), + return this.jsonApiService.get(url, params).pipe( + map((response: MyProjectsResponseJsonApi) => ({ + data: response.data.map((item: MyProjectsItemGetResponseJsonApi) => MyProjectsMapper.fromResponse(item)), links: response.links, })) ); @@ -81,7 +81,7 @@ export class MyProjectsService { filters?: MyProjectsSearchFilters, pageNumber?: number, pageSize?: number - ): Observable { + ): Observable { return this.getMyItems('nodes', filters, pageNumber, pageSize); } @@ -104,7 +104,7 @@ export class MyProjectsService { filters?: MyProjectsSearchFilters, pageNumber?: number, pageSize?: number - ): Observable { + ): Observable { return this.getMyItems('registrations', filters, pageNumber, pageSize); } @@ -112,7 +112,7 @@ export class MyProjectsService { filters?: MyProjectsSearchFilters, pageNumber?: number, pageSize?: number - ): Observable { + ): Observable { return this.getMyItems('preprints', filters, pageNumber, pageSize); } @@ -121,7 +121,7 @@ export class MyProjectsService { filters?: MyProjectsSearchFilters, pageNumber?: number, pageSize?: number - ): Observable { + ): Observable { return this.getMyItems(`collections/${collectionId}/linked_nodes/`, filters, pageNumber, pageSize); } @@ -132,7 +132,7 @@ export class MyProjectsService { region: string, affiliations: string[] ): Observable { - const payload: CreateProjectPayload = { + const payload: CreateProjectPayloadJsoApi = { data: { type: 'nodes', attributes: { @@ -167,7 +167,7 @@ export class MyProjectsService { }; return this.jsonApiService - .post(`${environment.apiUrl}/nodes/`, payload, params) + .post(`${environment.apiUrl}/nodes/`, payload, params) .pipe(map((response) => MyProjectsMapper.fromResponse(response))); } diff --git a/src/app/features/project/addons/addons.component.html b/src/app/features/project/addons/addons.component.html index 2c4502a91..cf196f066 100644 --- a/src/app/features/project/addons/addons.component.html +++ b/src/app/features/project/addons/addons.component.html @@ -58,7 +58,7 @@ @if (!isAddonsLoading()) { } @else { -
+
} @@ -70,14 +70,13 @@ [placeholder]="'settings.addons.filters.search' | translate" /> - @if (!isAddonsLoading()) { + @if (!isConfiguredAddonsLoading()) { } @else { -
+
} diff --git a/src/app/features/project/addons/addons.component.ts b/src/app/features/project/addons/addons.component.ts index 5da8e2f1d..20f82284f 100644 --- a/src/app/features/project/addons/addons.component.ts +++ b/src/app/features/project/addons/addons.component.ts @@ -5,7 +5,18 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Select } from 'primeng/select'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, OnInit, signal } from '@angular/core'; +import { debounceTime, distinctUntilChanged } from 'rxjs'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + OnInit, + signal, +} from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; @@ -17,6 +28,7 @@ import { ADDON_CATEGORY_OPTIONS, ADDON_TAB_OPTIONS } from '@shared/constants'; import { AddonCategory, AddonTabValue } from '@shared/enums'; import { AddonsSelectors, + ClearConfiguredAddons, DeleteAuthorizedAddon, GetAddonsResourceReference, GetAddonsUserReference, @@ -49,12 +61,14 @@ import { IS_XSMALL } from '@shared/utils'; }) export class AddonsComponent implements OnInit { private route = inject(ActivatedRoute); + private destroyRef = inject(DestroyRef); protected readonly tabOptions = ADDON_TAB_OPTIONS; protected readonly categoryOptions = ADDON_CATEGORY_OPTIONS; protected isMobile = toSignal(inject(IS_XSMALL)); protected AddonTabValue = AddonTabValue; protected defaultTabValue = AddonTabValue.ALL_ADDONS; protected searchControl = new FormControl(''); + protected searchValue = signal(''); protected selectedCategory = signal(AddonCategory.EXTERNAL_STORAGE_SERVICES); protected selectedTab = signal(this.defaultTabValue); @@ -78,6 +92,14 @@ export class AddonsComponent implements OnInit { this.isStorageAddonsLoading() || this.isCitationAddonsLoading() || this.isUserReferenceLoading() || + // this.isResourceReferenceLoading() || + this.isCurrentUserLoading() + ); + }); + protected isConfiguredAddonsLoading = computed(() => { + return ( + this.isConfiguredStorageAddonsLoading() || + this.isConfiguredCitationAddonsLoading() || this.isResourceReferenceLoading() || this.isCurrentUserLoading() ); @@ -91,6 +113,7 @@ export class AddonsComponent implements OnInit { getAddonsUserReference: GetAddonsUserReference, getAddonsResourceReference: GetAddonsResourceReference, deleteAuthorizedAddon: DeleteAuthorizedAddon, + clearConfiguredAddons: ClearConfiguredAddons, }); protected readonly userReferenceId = computed(() => { @@ -100,8 +123,8 @@ export class AddonsComponent implements OnInit { protected allConfiguredAddons = computed(() => { const authorizedAddons = [...this.configuredStorageAddons(), ...this.configuredCitationAddons()]; - const searchValue = this.searchControl.value?.toLowerCase() ?? ''; - return authorizedAddons.filter((card) => card.displayName.includes(searchValue)); + const searchValue = this.searchValue().toLowerCase(); + return authorizedAddons.filter((card) => card.displayName.toLowerCase().includes(searchValue)); }); protected resourceReferenceId = computed(() => { @@ -119,7 +142,7 @@ export class AddonsComponent implements OnInit { ); protected filteredAddonCards = computed(() => { - const searchValue = this.searchControl.value?.toLowerCase() ?? ''; + const searchValue = this.searchValue().toLowerCase(); return this.currentAddonsState().filter( (card) => card.externalServiceName.toLowerCase().includes(searchValue) || @@ -139,7 +162,7 @@ export class AddonsComponent implements OnInit { }); effect(() => { - if (this.currentUser() && this.userReferenceId()) { + if (this.currentUser()) { const action = this.currentAction(); const addons = this.currentAddonsState(); @@ -155,12 +178,22 @@ export class AddonsComponent implements OnInit { this.fetchAllConfiguredAddons(resourceReferenceId); } }); + + this.searchControl.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((value) => { + this.searchValue.set(value ?? ''); + }); + + effect(() => { + this.destroyRef.onDestroy(() => { + this.actions.clearConfiguredAddons(); + }); + }); } ngOnInit(): void { const projectId = this.route.parent?.parent?.snapshot.params['id']; - if (projectId && !this.addonsResourceReference()) { + if (projectId && !this.addonsResourceReference().length) { this.actions.getAddonsResourceReference(projectId); } } diff --git a/src/app/features/project/addons/components/configure-addon/configure-addon.component.html b/src/app/features/project/addons/components/configure-addon/configure-addon.component.html new file mode 100644 index 000000000..770171061 --- /dev/null +++ b/src/app/features/project/addons/components/configure-addon/configure-addon.component.html @@ -0,0 +1,182 @@ + + +
+ @if (!isEditMode()) { +
+
+

+ {{ 'settings.addons.connectAddon.configure' | translate }} {{ addon()?.externalServiceName }} +

+ +
+ + @if (selectedFolderName()) { +
+ +
+

+ {{ 'settings.addons.configureAddon.connectedAccount' | translate }} + {{ addon()?.displayName }} +

+ +
+ + +
+
+ +

+ {{ 'settings.addons.configureAddon.selectedFolder' | translate }} + {{ selectedFolderName() }} +

+
+
+ } @else { + + } +
+ } @else { +
+
+

+ {{ 'settings.addons.connectAddon.configure' | translate }} {{ addon()?.externalServiceName }} +

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

+ {{ 'settings.addons.configureAddon.selectedFolder' | translate }} + {{ selectedFolderName() }} +

+

+ {{ 'settings.addons.form.fields.accountName' | translate }} +

+ + +
+
+ + @if (isOperationInvocationSubmitting()) { + + } @else { +
+
+

Folder Name

+

Select

+
+ @if (operationInvocation()?.operationResult?.length) { + @for (folder of operationInvocation()?.operationResult; track folder.itemId) { + @let operationName = + folder.mayContainRootCandidates ? OperationNames.LIST_CHILD_ITEMS : OperationNames.GET_ITEM_INFO; + @let itemId = folder.itemId || '/'; +
+
+ + @if (folder.canBeRoot) { + + } +
+
+ } + } @else { +
+

{{ 'settings.addons.configureAddon.noFolders' | translate }}

+
+ } +
+ } + +
+ + +
+
+ } +
diff --git a/src/app/features/project/addons/components/configure-addon/configure-addon.component.scss b/src/app/features/project/addons/components/configure-addon/configure-addon.component.scss new file mode 100644 index 000000000..da0c027b5 --- /dev/null +++ b/src/app/features/project/addons/components/configure-addon/configure-addon.component.scss @@ -0,0 +1,5 @@ +:host { + display: flex; + flex-direction: column; + flex: 1; +} diff --git a/src/app/features/project/addons/components/configure-addon/configure-addon.component.spec.ts b/src/app/features/project/addons/components/configure-addon/configure-addon.component.spec.ts new file mode 100644 index 000000000..a2a1fc83d --- /dev/null +++ b/src/app/features/project/addons/components/configure-addon/configure-addon.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConfigureAddonComponent } from './configure-addon.component'; + +describe('ConfigureAddonComponent', () => { + let component: ConfigureAddonComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ConfigureAddonComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ConfigureAddonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/project/addons/components/configure-addon/configure-addon.component.ts b/src/app/features/project/addons/components/configure-addon/configure-addon.component.ts new file mode 100644 index 000000000..b98958f81 --- /dev/null +++ b/src/app/features/project/addons/components/configure-addon/configure-addon.component.ts @@ -0,0 +1,277 @@ +import { createDispatchMap, select, Store } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { MenuItem } from 'primeng/api'; +import { BreadcrumbModule } from 'primeng/breadcrumb'; +import { Button } from 'primeng/button'; +import { Card } from 'primeng/card'; +import { DialogService } from 'primeng/dynamicdialog'; +import { InputText } from 'primeng/inputtext'; +import { RadioButton } from 'primeng/radiobutton'; +import { Skeleton } from 'primeng/skeleton'; + +import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; + +import { DisconnectAddonModalComponent } from '@osf/features/project/addons/components'; +import { OperationNames } from '@osf/features/project/addons/enums'; +import { SubHeaderComponent } from '@shared/components'; +import { ConfiguredAddon, ConfiguredAddonRequestJsonApi, OperationInvocationRequestJsonApi } from '@shared/models'; +import { AddonsSelectors, CreateAddonOperationInvocation, UpdateConfiguredAddon } from '@shared/stores/addons'; + +@Component({ + selector: 'osf-configure-addon', + imports: [ + SubHeaderComponent, + TranslatePipe, + Button, + RouterLink, + Card, + InputText, + RadioButton, + ReactiveFormsModule, + FormsModule, + Skeleton, + BreadcrumbModule, + ], + templateUrl: './configure-addon.component.html', + styleUrl: './configure-addon.component.scss', + providers: [DialogService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfigureAddonComponent implements OnInit { + private readonly dialogService = inject(DialogService); + private readonly translateService = inject(TranslateService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly store = inject(Store); + + protected readonly OperationNames = OperationNames; + protected accountNameControl = new FormControl(''); + protected addon = signal(null); + protected isEditMode = signal(false); + protected chosenRootFolderId = signal(''); + protected addonsUserReference = select(AddonsSelectors.getAddonsUserReference); + protected operationInvocation = select(AddonsSelectors.getOperationInvocation); + protected selectedFolderName = select(AddonsSelectors.getSelectedFolderName); + + protected isOperationInvocationSubmitting = select(AddonsSelectors.getOperationInvocationSubmitting); + protected isAddonUpdateSubmitting = select(AddonsSelectors.getCreatedOrUpdatedConfiguredAddonSubmitting); + + protected readonly baseUrl = computed(() => { + const currentUrl = this.router.url; + return currentUrl.split('/addons')[0]; + }); + protected readonly resourceUri = computed(() => { + const id = this.route.parent?.parent?.snapshot.params['id']; + return `https://staging4.osf.io/${id}`; + }); + protected readonly addonTypeString = computed(() => { + const addon = this.addon(); + return addon?.type === 'configured-storage-addons' ? 'storage' : 'citation'; + }); + protected readonly actions = createDispatchMap({ + createAddonOperationInvocation: CreateAddonOperationInvocation, + updateConfiguredAddon: UpdateConfiguredAddon, + }); + protected breadcrumbItems = signal([]); + protected homeBreadcrumb: MenuItem = { + id: '/', + label: this.translateService.instant('settings.addons.configureAddon.home'), + state: { + operationName: OperationNames.LIST_ROOT_ITEMS, + }, + }; + + constructor() { + this.initializeAddon(); + } + + private initializeAddon(): void { + const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as ConfiguredAddon; + + if (addon) { + this.addon.set(addon); + this.accountNameControl.setValue(addon.displayName); + this.chosenRootFolderId.set(addon.selectedFolderId); + } else { + this.router.navigate([`${this.baseUrl()}/addons`]); + } + } + + protected handleCreateOperationInvocation( + operationName: OperationNames, + folderId: string, + folderName?: string, + mayContainRootCandidates?: boolean + ): void { + const addon = this.addon(); + if (!addon) return; + + const operationKwargs = this.getOperationKwargs(operationName, folderId); + + const payload: OperationInvocationRequestJsonApi = { + data: { + type: 'addon-operation-invocations', + attributes: { + invocation_status: null, + operation_name: operationName, + operation_kwargs: operationKwargs, + operation_result: {}, + created: null, + modified: null, + }, + relationships: { + thru_addon: { + data: { + type: addon.type, + id: addon.id, + }, + }, + }, + }, + }; + + this.actions.createAddonOperationInvocation(payload).subscribe({ + complete: () => { + this.handleBreadcrumbUpdate(operationName, folderId, folderName, mayContainRootCandidates); + }, + }); + } + + private handleBreadcrumbUpdate( + operationName: OperationNames, + folderId: string, + folderName?: string, + mayContainRootCandidates?: boolean + ): void { + if (operationName === OperationNames.LIST_ROOT_ITEMS) { + this.breadcrumbItems.set([]); + return; + } + + if (folderName) { + const breadcrumbs = [...this.breadcrumbItems()]; + + if (mayContainRootCandidates) { + const item = { + id: folderId, + label: folderName, + state: { + operationName: mayContainRootCandidates ? OperationNames.LIST_CHILD_ITEMS : OperationNames.GET_ITEM_INFO, + }, + }; + + this.breadcrumbItems.set([...breadcrumbs, { ...item }]); + } + } + } + + ngOnInit(): void { + this.handleCreateOperationInvocation(OperationNames.GET_ITEM_INFO, this.chosenRootFolderId()); + } + + protected handleDisconnectAccount(): void { + const currentAddon = this.addon(); + if (!currentAddon) return; + + this.openDisconnectDialog(currentAddon); + } + + private openDisconnectDialog(addon: ConfiguredAddon): void { + const dialogRef = this.dialogService.open(DisconnectAddonModalComponent, { + focusOnShow: false, + header: this.translateService.instant('settings.addons.configureAddon.disconnect', { + addonName: addon.externalServiceName, + }), + closeOnEscape: true, + modal: true, + closable: true, + data: { + message: this.translateService.instant('settings.addons.configureAddon.disconnectMessage'), + addon, + }, + }); + + dialogRef.onClose.subscribe((result) => { + if (result?.success) { + this.router.navigate([`${this.baseUrl()}/addons`]); + } + }); + } + + protected toggleEditMode(): void { + const operationResult = this.operationInvocation()?.operationResult[0]; + const hasRootCandidates = operationResult?.mayContainRootCandidates ?? false; + const itemId = operationResult?.itemId || '/'; + + this.handleCreateOperationInvocation( + hasRootCandidates ? OperationNames.LIST_CHILD_ITEMS : OperationNames.GET_ITEM_INFO, + itemId + ); + this.isEditMode.set(!this.isEditMode()); + } + + protected handleUpdateAddonConfiguration(): void { + const currentAddon = this.addon(); + if (!currentAddon) return; + + const payload = this.generateUpdatePayload(currentAddon); + + this.store.dispatch(new UpdateConfiguredAddon(payload, this.addonTypeString(), currentAddon.id)).subscribe({ + complete: () => this.router.navigate([`${this.baseUrl()}/addons`]), + }); + } + + private generateUpdatePayload(addon: ConfiguredAddon): ConfiguredAddonRequestJsonApi { + const addonType = this.addonTypeString(); + + const updatePayload = { + data: { + id: addon.id, + type: `configured-${addonType}-addons`, + attributes: { + authorized_resource_uri: this.resourceUri(), + display_name: this.accountNameControl.value || '', + root_folder: this.chosenRootFolderId(), + connected_capabilities: ['UPDATE', 'ACCESS'], + connected_operation_names: ['list_child_items', 'list_root_items', 'get_item_info'], + external_service_name: addon.externalServiceName, + }, + relationships: { + account_owner: { + data: { + type: 'user-references', + id: this.addonsUserReference()[0].id || '', + }, + }, + base_account: { + data: { + type: addon.baseAccountType, + id: addon.baseAccountId, + }, + }, + [`external_${addonType}_service`]: { + data: { + type: `external-${addonType}-services`, + id: addon.externalServiceName, + }, + }, + }, + }, + }; + + return updatePayload; + } + + private getOperationKwargs(operationName: OperationNames, folderId: string): Record { + const baseKwargs = operationName !== OperationNames.LIST_ROOT_ITEMS ? { item_id: folderId } : {}; + + return { + ...baseKwargs, + ...(operationName === OperationNames.LIST_CHILD_ITEMS && { item_type: 'FOLDER' }), + }; + } +} diff --git a/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.html b/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.html new file mode 100644 index 000000000..0a296e7e6 --- /dev/null +++ b/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.html @@ -0,0 +1,17 @@ +

{{ dialogMessage }}

+
+
+ + +
diff --git a/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.scss b/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.spec.ts b/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.spec.ts new file mode 100644 index 000000000..d496ce136 --- /dev/null +++ b/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConfirmAccountConnectionModalComponent } from './confirm-account-connection-modal.component'; + +describe('ConfirmAccountConnectionModalComponent', () => { + let component: ConfirmAccountConnectionModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ConfirmAccountConnectionModalComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ConfirmAccountConnectionModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.ts b/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.ts new file mode 100644 index 000000000..0bdbada14 --- /dev/null +++ b/src/app/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component.ts @@ -0,0 +1,60 @@ +import { select, Store } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { OperationInvocationRequestJsonApi } from '@shared/models'; +import { AddonsSelectors, CreateAddonOperationInvocation } from '@shared/stores/addons'; + +@Component({ + selector: 'osf-confirm-account-connection-modal', + imports: [Button, ReactiveFormsModule, TranslatePipe], + templateUrl: './confirm-account-connection-modal.component.html', + styleUrl: './confirm-account-connection-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfirmAccountConnectionModalComponent { + private dialogConfig = inject(DynamicDialogConfig); + private store = inject(Store); + protected dialogRef = inject(DynamicDialogRef); + protected dialogMessage = this.dialogConfig.data.message || ''; + protected isSubmitting = select(AddonsSelectors.getOperationInvocationSubmitting); + + protected handleConnectAddonAccount(): void { + const selectedAccount = this.dialogConfig.data.selectedAccount; + if (!selectedAccount) return; + + const payload: OperationInvocationRequestJsonApi = { + data: { + type: 'addon-operation-invocations', + attributes: { + invocation_status: null, + operation_name: 'list_root_items', + operation_kwargs: {}, + operation_result: {}, + created: null, + modified: null, + }, + relationships: { + thru_account: { + data: { + type: selectedAccount.type, + id: selectedAccount.id, + }, + }, + }, + }, + }; + + this.store.dispatch(new CreateAddonOperationInvocation(payload)).subscribe({ + complete: () => { + this.dialogRef.close({ success: true }); + }, + }); + } +} diff --git a/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.html b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.html similarity index 76% rename from src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.html rename to src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.html index 109ed5008..83fb69876 100644 --- a/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.html +++ b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.html @@ -100,17 +100,15 @@

{{ loginOrChooseAccountText() }}

-

- {{ 'settings.addons.connectAddon.chooseExistingAccount' | translate }} {{ addon()?.displayName }} -

+

{{ 'settings.addons.connectAddon.chooseExistingAccount' | translate }}

    @for (account of currentAuthorizedAddonAccounts(); track account.id) {
  • @@ -125,8 +123,84 @@

    > +

+ + + + + + +
+

{{ 'settings.addons.connectAddon.configure' | translate }} {{ addon()?.displayName }}

+ + +

+ {{ 'settings.addons.form.fields.accountName' | translate }} +

+ + +
+ +
+
+

Folder Name

+

Select

+
+ + @if (!operationInvocation()?.operationResult?.length) { +

+ {{ 'settings.addons.configureAddon.noFolders' | translate }} +

+ } @else { + @for (folder of operationInvocation()?.operationResult; track folder.itemId) { + @if (folder.canBeRoot) { +
+
+ @if (folder.itemId !== '/') { + + } +
+ + {{ folder.itemName }} +
+ + +
+
+ } + } + } +
+ +
+ +
@@ -135,7 +209,7 @@

-
+

{{ 'settings.addons.connectAddon.setupNewAccount' | translate }}

@if (addon()?.credentialsFormat === credentialsFormat.ACCESS_SECRET_KEYS) { @@ -237,7 +311,6 @@

- type="submit" [disabled]="!addonForm.valid || !this.addonsUserReference().length || isAddonConnecting()" > - - - - - - - - - - - - - - - - -
diff --git a/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.scss b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.scss similarity index 51% rename from src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.scss rename to src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.scss index fea06004b..06b5e393e 100644 --- a/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.scss +++ b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.scss @@ -14,8 +14,17 @@ } } - .folders-list { - border: 1px solid var.$grey-2; - border-radius: 0.57rem; + .filename-link { + cursor: pointer; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + max-width: 100%; + + &:hover { + text-decoration: underline; + } } } diff --git a/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.spec.ts b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.spec.ts similarity index 83% rename from src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.spec.ts rename to src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.spec.ts index a7df98958..d1a866264 100644 --- a/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.spec.ts +++ b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.spec.ts @@ -10,16 +10,15 @@ import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { ActivatedRoute, Navigation, Router, UrlTree } from '@angular/router'; import { SubHeaderComponent } from '@osf/shared/components'; +import { CredentialsFormat } from '@shared/enums'; +import { Addon } from '@shared/models'; import { AddonsSelectors } from '@shared/stores/addons'; -import { CredentialsFormat } from '../../enums'; -import { Addon } from '../../models'; - -import { ConnectConfigureAddonComponent } from './connect-configure-addon.component'; +import { ConnectConfiguredAddonComponent } from './connect-configured-addon.component'; describe('ConnectAddonComponent', () => { - let component: ConnectConfigureAddonComponent; - let fixture: ComponentFixture; + let component: ConnectConfiguredAddonComponent; + let fixture: ComponentFixture; const mockAddon: Addon = { id: 'test-addon-id', @@ -45,7 +44,7 @@ describe('ConnectAddonComponent', () => { }; await TestBed.configureTestingModule({ - imports: [ConnectConfigureAddonComponent, MockComponent(SubHeaderComponent), MockPipe(TranslatePipe)], + imports: [ConnectConfiguredAddonComponent, MockComponent(SubHeaderComponent), MockPipe(TranslatePipe)], providers: [ provideNoopAnimations(), MockProvider(Store, { @@ -69,7 +68,7 @@ describe('ConnectAddonComponent', () => { ], }).compileComponents(); - fixture = TestBed.createComponent(ConnectConfigureAddonComponent); + fixture = TestBed.createComponent(ConnectConfiguredAddonComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.ts b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.ts similarity index 66% rename from src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.ts rename to src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.ts index 2176e0936..ff3858362 100644 --- a/src/app/features/project/addons/components/connect-configure-addon/connect-configure-addon.component.ts +++ b/src/app/features/project/addons/components/connect-configured-addon/connect-configured-addon.component.ts @@ -4,6 +4,7 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; +import { DialogService } from 'primeng/dynamicdialog'; import { InputText } from 'primeng/inputtext'; import { Password } from 'primeng/password'; import { RadioButtonModule } from 'primeng/radiobutton'; @@ -13,24 +14,33 @@ import { TableModule } from 'primeng/table'; import { NgClass } from '@angular/common'; import { Component, computed, inject, signal, viewChild } from '@angular/core'; import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; -import { Router, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ConfirmAccountConnectionModalComponent } from '@osf/features/project/addons/components'; import { AddonConfigMap } from '@osf/features/project/addons/utils'; import { SubHeaderComponent } from '@osf/shared/components'; import { ADDON_TERMS as addonTerms } from '@osf/shared/constants'; import { AddonFormControls, CredentialsFormat, ProjectAddonsStepperValue } from '@osf/shared/enums'; -import { SettingsAddonsStepperValue } from '@shared/enums/settings-addons-stepper.enum'; -import { Addon, AddonForm, AddonRequest, AddonTerm, AuthorizedAddon } from '@shared/models'; +import { + Addon, + AddonForm, + AddonTerm, + AuthorizedAddon, + AuthorizedAddonRequestJsonApi, + ConfiguredAddonRequestJsonApi, +} from '@shared/models'; import { AddonsSelectors, CreateAuthorizedAddon, + CreateConfiguredAddon, GetAuthorizedCitationAddons, GetAuthorizedStorageAddons, UpdateAuthorizedAddon, + UpdateConfiguredAddon, } from '@shared/stores/addons'; @Component({ - selector: 'osf-connect-configure-addon', + selector: 'osf-connect-configured-addon', imports: [ SubHeaderComponent, StepPanel, @@ -48,13 +58,15 @@ import { TranslatePipe, RadioButtonModule, ], - templateUrl: './connect-configure-addon.component.html', - providers: [RadioButtonModule], - styleUrl: './connect-configure-addon.component.scss', + templateUrl: './connect-configured-addon.component.html', + providers: [RadioButtonModule, DialogService], + styleUrl: './connect-configured-addon.component.scss', }) -export class ConnectConfigureAddonComponent { +export class ConnectConfiguredAddonComponent { private translateService = inject(TranslateService); + private dialogService = inject(DialogService); private router = inject(Router); + private route = inject(ActivatedRoute); private fb = inject(FormBuilder); protected stepper = viewChild(Stepper); protected AddonStepperValue = ProjectAddonsStepperValue; @@ -64,21 +76,27 @@ export class ConnectConfigureAddonComponent { protected addon = signal(null); protected addonAuthUrl = signal('/settings/addons'); protected currentAuthorizedAddonAccounts = signal([]); - protected chosenAccount = ''; + protected chosenAccountId = signal(''); + protected chosenAccountName = signal(''); + protected chosenRootFolderId = signal(''); protected addonsUserReference = select(AddonsSelectors.getAddonsUserReference); - protected createdAddon = select(AddonsSelectors.getCreatedOrUpdatedAuthorizedAddon); + protected createdAuthorizedAddon = select(AddonsSelectors.getCreatedOrUpdatedAuthorizedAddon); + protected createdConfiguredAddon = select(AddonsSelectors.getCreatedOrUpdatedConfiguredAddon); protected authorizedStorageAddons = select(AddonsSelectors.getAuthorizedStorageAddons); protected authorizedCitationAddons = select(AddonsSelectors.getAuthorizedCitationAddons); + protected operationInvocation = select(AddonsSelectors.getOperationInvocation); protected isAuthorizedStorageAddonsLoading = select(AddonsSelectors.getAuthorizedStorageAddonsLoading); protected isAuthorizedCitationAddonsLoading = select(AddonsSelectors.getAuthorizedCitationAddonsLoading); - protected isAddonConnecting = select(AddonsSelectors.getCreatedOrUpdatedStorageAddonSubmitting); + protected isAddonConnecting = select(AddonsSelectors.getCreatedOrUpdatedConfiguredAddonSubmitting); protected actions = createDispatchMap({ getAuthorizedStorageAddons: GetAuthorizedStorageAddons, getAuthorizedCitationAddons: GetAuthorizedCitationAddons, createAuthorizedAddon: CreateAuthorizedAddon, + createConfiguredAddon: CreateConfiguredAddon, + updateConfiguredAddon: UpdateConfiguredAddon, updateAuthorizedAddon: UpdateAuthorizedAddon, }); @@ -92,20 +110,19 @@ export class ConnectConfigureAddonComponent { }); }); - // protected isAuthorized = computed(() => { - // //check if the addon is already authorized - // const addon = this.addon(); - // if (addon) { - // return addon.type === 'authorized-storage-accounts' || addon.type === 'authorized-citation-accounts'; - // } - // return false; - // }); + protected resourceUri = computed(() => { + const id = this.route.parent?.parent?.snapshot.params['id']; + + return `https://staging4.osf.io/${id}`; + }); protected addonTypeString = computed(() => { - //get the addon type string based on the addon type property const addon = this.addon(); + if (addon) { - return addon.type === 'external-storage-services' || addon.type === 'authorized-storage-accounts' + return addon.type === 'external-storage-services' || + addon.type === 'authorized-storage-accounts' || + addon.type === 'configured-storage-addons' ? 'storage' : 'citation'; } @@ -119,28 +136,72 @@ export class ConnectConfigureAddonComponent { }); constructor() { - const terms = this.getTerms(); + const terms = this.getAddonTerms(); this.terms.set(terms); this.addonForm = this.initializeForm(); } - protected handleConnectAddon(): void { + protected handleCreateConfiguredAddon() { + if (!this.addon()) return; + + const payload = this.generateConfiguredAddonRequestPayload(); + + this.actions.createConfiguredAddon(payload, this.addonTypeString()).subscribe({ + complete: () => { + const createdAddon = this.createdConfiguredAddon(); + if (createdAddon) { + this.router.navigate([`${this.baseUrl()}/addons`]); + } + }, + }); + } + + protected handleCreateAuthorizedAddon(): void { if (!this.addon() || !this.addonForm.valid) return; - const request = this.generateRequestPayload(); + const payload = this.generateAuthorizedAddonRequestPayload(); - this.actions.createAuthorizedAddon(request, this.addonTypeString()).subscribe({ + this.actions.createAuthorizedAddon(payload, this.addonTypeString()).subscribe({ complete: () => { - const createdAddon = this.createdAddon(); + const createdAddon = this.createdAuthorizedAddon(); if (createdAddon) { this.addonAuthUrl.set(createdAddon.attributes.auth_url); window.open(createdAddon.attributes.auth_url, '_blank'); - this.stepper()?.value.set(SettingsAddonsStepperValue.AUTH); + this.stepper()?.value.set(ProjectAddonsStepperValue.AUTH); } }, }); } + protected handleConfirmAccountConnection(): void { + const selectedAccount = this.currentAuthorizedAddonAccounts().find( + (account) => account.id === this.chosenAccountId() + ); + + if (!selectedAccount) return; + + const dialogRef = this.dialogService.open(ConfirmAccountConnectionModalComponent, { + focusOnShow: false, + header: this.translateService.instant('settings.addons.connectAddon.confirmAccount'), + closeOnEscape: true, + modal: true, + closable: true, + data: { + message: this.translateService.instant('settings.addons.connectAddon.connectAccount', { + accountName: selectedAccount.displayName, + }), + selectedAccount, + }, + }); + + dialogRef.onClose.subscribe((result) => { + if (result?.success) { + this.stepper()?.value.set(ProjectAddonsStepperValue.CONFIGURE_ROOT_FOLDER); + this.chosenAccountName.set(selectedAccount.displayName); + } + }); + } + protected handleAuthorizedAccountsPresenceCheck() { const addonType = this.addonTypeString(); const referenceId = this.userReferenceId(); @@ -187,14 +248,14 @@ export class ConnectConfigureAddonComponent { } private initializeForm(): FormGroup { - const addon = this.addon(); + const currentAddon = this.addon(); - if (addon) { + if (currentAddon) { const formControls: Partial = { - [AddonFormControls.AccountName]: this.fb.control(addon.displayName || '', Validators.required), + [AddonFormControls.AccountName]: this.fb.control(currentAddon.displayName || '', Validators.required), }; - switch (addon.credentialsFormat) { + switch (currentAddon.credentialsFormat) { case CredentialsFormat.ACCESS_SECRET_KEYS: formControls[AddonFormControls.AccessKey] = this.fb.control('', Validators.required); formControls[AddonFormControls.SecretKey] = this.fb.control('', Validators.required); @@ -219,14 +280,15 @@ export class ConnectConfigureAddonComponent { return new FormGroup({} as AddonForm); } - private generateRequestPayload(): AddonRequest { + private generateAuthorizedAddonRequestPayload(): AuthorizedAddonRequestJsonApi { const formValue = this.addonForm.value; - const addon = this.addon()!; + const currentAddon = this.addon()!; const credentials: Record = {}; const initiateOAuth = - addon.credentialsFormat === CredentialsFormat.OAUTH2 || addon.credentialsFormat === CredentialsFormat.OAUTH; + currentAddon.credentialsFormat === CredentialsFormat.OAUTH2 || + currentAddon.credentialsFormat === CredentialsFormat.OAUTH; - switch (addon.credentialsFormat) { + switch (currentAddon.credentialsFormat) { case CredentialsFormat.ACCESS_SECRET_KEYS: credentials['access_key'] = formValue[AddonFormControls.AccessKey]; credentials['secret_key'] = formValue[AddonFormControls.SecretKey]; @@ -244,9 +306,9 @@ export class ConnectConfigureAddonComponent { break; } - const requestPayload: AddonRequest = { + const requestPayload: AuthorizedAddonRequestJsonApi = { data: { - id: addon.id || '', + id: currentAddon.id || '', attributes: { api_base_url: formValue[AddonFormControls.HostUrl] || '', display_name: formValue[AddonFormControls.AccountName] || '', @@ -263,7 +325,7 @@ export class ConnectConfigureAddonComponent { id: this.addonsUserReference()[0].id || '', }, }, - ...this.getServiceRelationship(addon), + ...this.getServiceRelationship(currentAddon), }, type: `authorized-${this.addonTypeString()}-accounts`, }, @@ -272,6 +334,44 @@ export class ConnectConfigureAddonComponent { return requestPayload; } + private generateConfiguredAddonRequestPayload(): ConfiguredAddonRequestJsonApi { + const currentAddon = this.addon()!; + const selectedAccount = this.currentAuthorizedAddonAccounts().find( + (account) => account.id === this.chosenAccountId() + ); + + const requestPayload: ConfiguredAddonRequestJsonApi = { + data: { + type: `configured-${this.addonTypeString()}-addons`, + attributes: { + authorized_resource_uri: this.resourceUri(), + display_name: selectedAccount!.displayName, + root_folder: this.chosenRootFolderId(), + connected_capabilities: ['UPDATE', 'ACCESS'], + connected_operation_names: ['list_child_items', 'list_root_items', 'get_item_info'], + external_service_name: currentAddon.externalServiceName, + }, + relationships: { + account_owner: { + data: { + type: 'user-references', + id: this.addonsUserReference()[0].id || '', + }, + }, + base_account: { + data: { + type: 'authorized-storage-accounts', + id: selectedAccount!.id, + }, + }, + ...this.getServiceRelationship(currentAddon), + }, + }, + }; + + return requestPayload; + } + private getServiceRelationship(addon: Addon | AuthorizedAddon) { const isAuthorizedAddon = addon.type === 'authorized-storage-accounts' || addon.type === 'authorized-citation-accounts'; @@ -288,10 +388,11 @@ export class ConnectConfigureAddonComponent { }; } - private getTerms(): AddonTerm[] { + private getAddonTerms(): AddonTerm[] { const addon = this.router.getCurrentNavigation()?.extras.state?.['addon'] as Addon | AuthorizedAddon; if (!addon) { this.router.navigate([`${this.baseUrl()}/addons`]); + return []; } this.addon.set(addon); diff --git a/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.html b/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.html new file mode 100644 index 000000000..fe482900d --- /dev/null +++ b/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.html @@ -0,0 +1,26 @@ +

{{ dialogMessage }}

+
+

+ {{ 'settings.addons.configureAddon.account' | translate }} {{ addon.displayName }} +

+

+ {{ 'settings.addons.configureAddon.selectedFolder' | translate }} + {{ selectedFolderName() }} +

+
+ + +
diff --git a/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.scss b/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.spec.ts b/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.spec.ts new file mode 100644 index 000000000..38a08ddf3 --- /dev/null +++ b/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DisconnectAddonModalComponent } from './disconnect-addon-modal.component'; + +describe('DisconnectAddonModalComponent', () => { + let component: DisconnectAddonModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DisconnectAddonModalComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DisconnectAddonModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.ts b/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.ts new file mode 100644 index 000000000..056b83657 --- /dev/null +++ b/src/app/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component.ts @@ -0,0 +1,37 @@ +import { select, Store } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; + +import { AddonsSelectors, DeleteConfiguredAddon } from '@shared/stores/addons'; + +@Component({ + selector: 'osf-disconnect-addon-modal', + imports: [Button, TranslatePipe], + templateUrl: './disconnect-addon-modal.component.html', + styleUrl: './disconnect-addon-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DisconnectAddonModalComponent { + private dialogConfig = inject(DynamicDialogConfig); + private store = inject(Store); + protected dialogRef = inject(DynamicDialogRef); + protected addon = this.dialogConfig.data.addon; + protected dialogMessage = this.dialogConfig.data.message || ''; + protected isSubmitting = select(AddonsSelectors.getDeleteStorageAddonSubmitting); + protected selectedFolderName = select(AddonsSelectors.getSelectedFolderName); + + protected handleDisconnectAddonAccount(): void { + if (!this.addon) return; + + this.store.dispatch(new DeleteConfiguredAddon(this.addon.id, this.addon.type)).subscribe({ + complete: () => { + this.dialogRef.close({ success: true }); + }, + }); + } +} diff --git a/src/app/features/project/addons/components/index.ts b/src/app/features/project/addons/components/index.ts index b2129e835..0fb838b75 100644 --- a/src/app/features/project/addons/components/index.ts +++ b/src/app/features/project/addons/components/index.ts @@ -1 +1,4 @@ -export { ConnectConfigureAddonComponent } from '@osf/features/project/addons/components/connect-configure-addon/connect-configure-addon.component'; +export { ConfigureAddonComponent } from '@osf/features/project/addons/components/configure-addon/configure-addon.component'; +export { ConfirmAccountConnectionModalComponent } from '@osf/features/project/addons/components/confirm-account-connection-modal/confirm-account-connection-modal.component'; +export { ConnectConfiguredAddonComponent } from '@osf/features/project/addons/components/connect-configured-addon/connect-configured-addon.component'; +export { DisconnectAddonModalComponent } from '@osf/features/project/addons/components/disconnect-addon-modal/disconnect-addon-modal.component'; diff --git a/src/app/features/project/addons/enums/index.ts b/src/app/features/project/addons/enums/index.ts new file mode 100644 index 000000000..66bce9417 --- /dev/null +++ b/src/app/features/project/addons/enums/index.ts @@ -0,0 +1 @@ +export * from './operation-names.enum'; diff --git a/src/app/features/project/addons/enums/operation-names.enum.ts b/src/app/features/project/addons/enums/operation-names.enum.ts new file mode 100644 index 000000000..5469cc116 --- /dev/null +++ b/src/app/features/project/addons/enums/operation-names.enum.ts @@ -0,0 +1,5 @@ +export enum OperationNames { + LIST_ROOT_ITEMS = 'list_root_items', + LIST_CHILD_ITEMS = 'list_child_items', + GET_ITEM_INFO = 'get_item_info', +} diff --git a/src/app/features/project/overview/mappers/project-overview.mapper.ts b/src/app/features/project/overview/mappers/project-overview.mapper.ts index 44ba27529..bb6286097 100644 --- a/src/app/features/project/overview/mappers/project-overview.mapper.ts +++ b/src/app/features/project/overview/mappers/project-overview.mapper.ts @@ -1,7 +1,12 @@ -import { ComponentGetResponse, ComponentOverview, ProjectOverview, ProjectOverviewGetResponse } from '../models'; +import { + ComponentGetResponseJsoApi, + ComponentOverview, + ProjectOverview, + ProjectOverviewGetResponseJsoApi, +} from '../models'; export class ProjectOverviewMapper { - static fromGetProjectResponse(response: ProjectOverviewGetResponse): ProjectOverview { + static fromGetProjectResponse(response: ProjectOverviewGetResponseJsoApi): ProjectOverview { return { id: response.id, type: response.type, @@ -74,7 +79,7 @@ export class ProjectOverviewMapper { }; } - static fromGetComponentResponse(response: ComponentGetResponse): ComponentOverview { + static fromGetComponentResponse(response: ComponentGetResponseJsoApi): ComponentOverview { return { id: response.id, type: response.type, diff --git a/src/app/features/project/overview/models/project-overview.models.ts b/src/app/features/project/overview/models/project-overview.models.ts index 28dfdff8a..5430d0521 100644 --- a/src/app/features/project/overview/models/project-overview.models.ts +++ b/src/app/features/project/overview/models/project-overview.models.ts @@ -18,7 +18,7 @@ export interface ComponentOverview { contributors: ProjectOverviewContributor[]; } -export interface ComponentGetResponse { +export interface ComponentGetResponseJsoApi { id: string; type: string; attributes: { @@ -119,7 +119,7 @@ export interface ProjectOverviewSubject { text: string; } -export interface ProjectOverviewGetResponse { +export interface ProjectOverviewGetResponseJsoApi { id: string; type: string; attributes: { @@ -255,6 +255,6 @@ export interface ProjectOverviewGetResponse { }; } -export interface ProjectOverviewJsonApiResponse extends JsonApiResponse { - data: ProjectOverviewGetResponse; +export interface ProjectOverviewResponseJsoApi extends JsonApiResponse { + data: ProjectOverviewGetResponseJsoApi; } diff --git a/src/app/features/project/overview/services/project-overview.service.ts b/src/app/features/project/overview/services/project-overview.service.ts index f5fe3b3cb..2d7466491 100644 --- a/src/app/features/project/overview/services/project-overview.service.ts +++ b/src/app/features/project/overview/services/project-overview.service.ts @@ -6,7 +6,12 @@ import { inject, Injectable } from '@angular/core'; import { JsonApiService } from '@osf/core/services'; import { ProjectOverviewMapper } from '../mappers'; -import { ComponentGetResponse, ComponentOverview, ProjectOverview, ProjectOverviewJsonApiResponse } from '../models'; +import { + ComponentGetResponseJsoApi, + ComponentOverview, + ProjectOverview, + ProjectOverviewResponseJsoApi, +} from '../models'; import { environment } from 'src/environments/environment'; @@ -33,7 +38,7 @@ export class ProjectOverviewService { }; return this.#jsonApiService - .get(`${environment.apiUrl}/nodes/${projectId}/`, params) + .get(`${environment.apiUrl}/nodes/${projectId}/`, params) .pipe(map((response) => ProjectOverviewMapper.fromGetProjectResponse(response.data))); } @@ -123,7 +128,7 @@ export class ProjectOverviewService { }; return this.#jsonApiService - .get<{ data: ComponentGetResponse[] }>(`${environment.apiUrl}/nodes/${projectId}/children`, params) + .get<{ data: ComponentGetResponseJsoApi[] }>(`${environment.apiUrl}/nodes/${projectId}/children`, params) .pipe(map((response) => response.data.map((item) => ProjectOverviewMapper.fromGetComponentResponse(item)))); } @@ -134,7 +139,7 @@ export class ProjectOverviewService { }; return this.#jsonApiService - .get<{ data: ComponentGetResponse[] }>(`${environment.apiUrl}/nodes/${projectId}/linked_nodes`, params) + .get<{ data: ComponentGetResponseJsoApi[] }>(`${environment.apiUrl}/nodes/${projectId}/linked_nodes`, params) .pipe(map((response) => response.data.map((item) => ProjectOverviewMapper.fromGetComponentResponse(item)))); } } diff --git a/src/app/features/settings/addons/addons.component.html b/src/app/features/settings/addons/addons.component.html index 34d9ca485..2b046909f 100644 --- a/src/app/features/settings/addons/addons.component.html +++ b/src/app/features/settings/addons/addons.component.html @@ -63,7 +63,9 @@ @if (!isAddonsLoading()) { } @else { - +
+ +
} @@ -73,11 +75,17 @@ [placeholder]="'settings.addons.filters.search' | translate" /> - + @if (!isAuthorizedAddonsLoading()) { + + } @else { +
+ +
+ }
diff --git a/src/app/features/settings/addons/addons.component.ts b/src/app/features/settings/addons/addons.component.ts index 6c5d78af0..123698939 100644 --- a/src/app/features/settings/addons/addons.component.ts +++ b/src/app/features/settings/addons/addons.component.ts @@ -6,6 +6,8 @@ import { AutoCompleteModule } from 'primeng/autocomplete'; import { SelectModule } from 'primeng/select'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; +import { debounceTime, distinctUntilChanged } from 'rxjs'; + import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule } from '@angular/forms'; @@ -57,6 +59,7 @@ export class AddonsComponent { protected AddonTabValue = AddonTabValue; protected defaultTabValue = AddonTabValue.ALL_ADDONS; protected searchControl = new FormControl(''); + protected searchValue = signal(''); protected selectedCategory = signal(AddonCategory.EXTERNAL_STORAGE_SERVICES); protected selectedTab = signal(this.defaultTabValue); @@ -67,13 +70,30 @@ export class AddonsComponent { protected authorizedStorageAddons = select(AddonsSelectors.getAuthorizedStorageAddons); protected authorizedCitationAddons = select(AddonsSelectors.getAuthorizedCitationAddons); + protected isCurrentUserLoading = select(UserSelectors.getCurrentUserLoading); + protected isUserReferenceLoading = select(AddonsSelectors.getAddonsUserReferenceLoading); protected isStorageAddonsLoading = select(AddonsSelectors.getStorageAddonsLoading); protected isCitationAddonsLoading = select(AddonsSelectors.getCitationAddonsLoading); protected isAuthorizedStorageAddonsLoading = select(AddonsSelectors.getAuthorizedStorageAddonsLoading); protected isAuthorizedCitationAddonsLoading = select(AddonsSelectors.getAuthorizedCitationAddonsLoading); + protected isAddonsLoading = computed(() => { - return this.isStorageAddonsLoading() || this.isCitationAddonsLoading(); + return ( + this.isStorageAddonsLoading() || + this.isCitationAddonsLoading() || + this.isUserReferenceLoading() || + this.isCurrentUserLoading() + ); + }); + protected isAuthorizedAddonsLoading = computed(() => { + return ( + this.isAuthorizedStorageAddonsLoading() || + this.isAuthorizedCitationAddonsLoading() || + this.isUserReferenceLoading() || + this.isCurrentUserLoading() + ); }); + protected actions = createDispatchMap({ getStorageAddons: GetStorageAddons, getCitationAddons: GetCitationAddons, @@ -88,7 +108,7 @@ export class AddonsComponent { protected readonly allAuthorizedAddons = computed(() => { const authorizedAddons = [...this.authorizedStorageAddons(), ...this.authorizedCitationAddons()]; - const searchValue = this.searchControl.value?.toLowerCase() ?? ''; + const searchValue = this.searchValue().toLowerCase(); return authorizedAddons.filter((card) => card.displayName.includes(searchValue)); }); @@ -107,8 +127,8 @@ export class AddonsComponent { ); protected readonly filteredAddonCards = computed(() => { - const searchValue = this.searchControl.value?.toLowerCase() ?? ''; - return this.currentAddonsState().filter((card) => card.externalServiceName.includes(searchValue)); + const searchValue = this.searchValue().toLowerCase(); + return this.currentAddonsState().filter((card) => card.externalServiceName.toLowerCase().includes(searchValue)); }); protected onCategoryChange(value: string): void { @@ -141,6 +161,10 @@ export class AddonsComponent { this.fetchAllAuthorizedAddons(this.userReferenceId()); } }); + + this.searchControl.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((value) => { + this.searchValue.set(value ?? ''); + }); } private fetchAllAuthorizedAddons(userReferenceId: string): void { diff --git a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.spec.ts b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.spec.ts index 941fe02c1..09cdde185 100644 --- a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.spec.ts +++ b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.spec.ts @@ -10,11 +10,10 @@ import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { ActivatedRoute, Navigation, Router, UrlTree } from '@angular/router'; import { SubHeaderComponent } from '@osf/shared/components'; +import { CredentialsFormat } from '@shared/enums'; +import { Addon } from '@shared/models'; import { AddonsSelectors } from '@shared/stores/addons'; -import { CredentialsFormat } from '../../enums'; -import { Addon } from '../../models'; - import { ConnectAddonComponent } from './connect-addon.component'; describe('ConnectAddonComponent', () => { diff --git a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts index a3b37cd7f..c965f9d53 100644 --- a/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts +++ b/src/app/features/settings/addons/components/connect-addon/connect-addon.component.ts @@ -17,7 +17,7 @@ import { Router, RouterLink } from '@angular/router'; import { SubHeaderComponent } from '@osf/shared/components'; import { ADDON_TERMS as addonTerms } from '@osf/shared/constants'; import { AddonFormControls, CredentialsFormat } from '@osf/shared/enums'; -import { Addon, AddonForm, AddonRequest, AddonTerm, AuthorizedAddon } from '@shared/models'; +import { Addon, AddonForm, AddonTerm, AuthorizedAddon, AuthorizedAddonRequestJsonApi } from '@shared/models'; import { AddonsSelectors, CreateAuthorizedAddon, UpdateAuthorizedAddon } from '@shared/stores/addons'; @Component({ @@ -142,7 +142,7 @@ export class ConnectAddonComponent { return new FormGroup({} as AddonForm); } - private generateRequestPayload(): AddonRequest { + private generateRequestPayload(): AuthorizedAddonRequestJsonApi { const formValue = this.addonForm.value; const addon = this.addon()!; const credentials: Record = {}; @@ -167,7 +167,7 @@ export class ConnectAddonComponent { break; } - const requestPayload: AddonRequest = { + const requestPayload: AuthorizedAddonRequestJsonApi = { data: { id: addon.id || '', attributes: { diff --git a/src/app/features/settings/tokens/mappers/token.mapper.ts b/src/app/features/settings/tokens/mappers/token.mapper.ts index f655d3759..45cc20602 100644 --- a/src/app/features/settings/tokens/mappers/token.mapper.ts +++ b/src/app/features/settings/tokens/mappers/token.mapper.ts @@ -1,7 +1,7 @@ -import { Token, TokenCreateRequest, TokenCreateResponse, TokenGetResponse } from '../models'; +import { Token, TokenCreateRequestJsonApi, TokenCreateResponseJsonApi, TokenGetResponseJsonApi } from '../models'; export class TokenMapper { - static toRequest(name: string, scopes: string[]): TokenCreateRequest { + static toRequest(name: string, scopes: string[]): TokenCreateRequestJsonApi { return { data: { attributes: { @@ -13,7 +13,7 @@ export class TokenMapper { }; } - static fromCreateResponse(response: TokenCreateResponse): Token { + static fromCreateResponse(response: TokenCreateResponseJsonApi): Token { return { id: response.id, name: response.attributes.name, @@ -23,7 +23,7 @@ export class TokenMapper { }; } - static fromGetResponse(response: TokenGetResponse): Token { + static fromGetResponse(response: TokenGetResponseJsonApi): Token { return { id: response.id, name: response.attributes.name, diff --git a/src/app/features/settings/tokens/models/tokens.model.ts b/src/app/features/settings/tokens/models/tokens.model.ts index b1cee985b..5fef6f9b4 100644 --- a/src/app/features/settings/tokens/models/tokens.model.ts +++ b/src/app/features/settings/tokens/models/tokens.model.ts @@ -1,5 +1,5 @@ // API Request Model -export interface TokenCreateRequest { +export interface TokenCreateRequestJsonApi { data: { attributes: { name: string; @@ -10,7 +10,7 @@ export interface TokenCreateRequest { } // API Response Model -export interface TokenCreateResponse { +export interface TokenCreateResponseJsonApi { id: string; type: 'tokens'; attributes: { @@ -22,7 +22,7 @@ export interface TokenCreateResponse { } // API Response Model for GET request -export interface TokenGetResponse { +export interface TokenGetResponseJsonApi { id: string; type: 'tokens'; attributes: { diff --git a/src/app/features/settings/tokens/services/tokens.service.ts b/src/app/features/settings/tokens/services/tokens.service.ts index d60b76daf..e9aa3feb8 100644 --- a/src/app/features/settings/tokens/services/tokens.service.ts +++ b/src/app/features/settings/tokens/services/tokens.service.ts @@ -7,7 +7,7 @@ import { JsonApiResponse } from '@osf/core/models'; import { JsonApiService } from '@osf/core/services'; import { TokenMapper } from '../mappers'; -import { Scope, Token, TokenCreateResponse, TokenGetResponse } from '../models'; +import { Scope, Token, TokenCreateResponseJsonApi, TokenGetResponseJsonApi } from '../models'; import { environment } from 'src/environments/environment'; @@ -24,16 +24,18 @@ export class TokensService { } getTokens(): Observable { - return this.#jsonApiService.get>(environment.apiUrl + '/tokens/').pipe( - map((responses) => { - return responses.data.map((response) => TokenMapper.fromGetResponse(response)); - }) - ); + return this.#jsonApiService + .get>(environment.apiUrl + '/tokens/') + .pipe( + map((responses) => { + return responses.data.map((response) => TokenMapper.fromGetResponse(response)); + }) + ); } getTokenById(tokenId: string): Observable { return this.#jsonApiService - .get>(`${environment.apiUrl}/tokens/${tokenId}/`) + .get>(`${environment.apiUrl}/tokens/${tokenId}/`) .pipe(map((response) => TokenMapper.fromGetResponse(response.data))); } @@ -41,7 +43,7 @@ export class TokensService { const request = TokenMapper.toRequest(name, scopes); return this.#jsonApiService - .post(environment.apiUrl + '/tokens/', request) + .post(environment.apiUrl + '/tokens/', request) .pipe(map((response) => TokenMapper.fromCreateResponse(response))); } @@ -49,7 +51,7 @@ export class TokensService { const request = TokenMapper.toRequest(name, scopes); return this.#jsonApiService - .patch(`${environment.apiUrl}/tokens/${tokenId}/`, request) + .patch(`${environment.apiUrl}/tokens/${tokenId}/`, request) .pipe(map((response) => TokenMapper.fromCreateResponse(response))); } diff --git a/src/app/features/settings/tokens/tokens.component.ts b/src/app/features/settings/tokens/tokens.component.ts index 0b751aab8..a03821970 100644 --- a/src/app/features/settings/tokens/tokens.component.ts +++ b/src/app/features/settings/tokens/tokens.component.ts @@ -40,12 +40,7 @@ export class TokensComponent implements OnInit { ); createToken(): void { - let dialogWidth = '850px'; - if (this.isXSmall()) { - dialogWidth = '345px'; - } else if (this.isMedium()) { - dialogWidth = '500px'; - } + const dialogWidth = this.isXSmall() ? '95vw' : '800px'; this.#dialogService.open(TokenAddEditFormComponent, { width: dialogWidth, diff --git a/src/app/shared/components/addons/addon-card/addon-card.component.html b/src/app/shared/components/addons/addon-card/addon-card.component.html index 41bb42f55..dd56792f3 100644 --- a/src/app/shared/components/addons/addon-card/addon-card.component.html +++ b/src/app/shared/components/addons/addon-card/addon-card.component.html @@ -21,7 +21,7 @@

{{ card()?.displayName }}

diff --git a/src/app/shared/components/addons/addon-card/addon-card.component.ts b/src/app/shared/components/addons/addon-card/addon-card.component.ts index 0ec7d6507..4f64251aa 100644 --- a/src/app/shared/components/addons/addon-card/addon-card.component.ts +++ b/src/app/shared/components/addons/addon-card/addon-card.component.ts @@ -32,10 +32,20 @@ export class AddonCardComponent { protected readonly addonTypeString = computed(() => { const addon = this.card(); if (addon) { - return addon.type === 'authorized-storage-accounts' ? 'storage' : 'citation'; + return addon.type === 'authorized-storage-accounts' || addon.type === 'configured-storage-addons' + ? 'storage' + : 'citation'; } return ''; }); + protected readonly isConfiguredAddon = computed(() => { + const addon = this.card(); + if (addon) { + return addon.type === 'configured-storage-addons' || addon.type === 'configured-citation-addons'; + } + + return false; + }); onConnectAddon(): void { const addon = this.card(); @@ -48,6 +58,17 @@ export class AddonCardComponent { } } + onConfigureAddon(): void { + const addon = this.card(); + if (addon) { + const currentUrl = this.router.url; + const baseUrl = currentUrl.split('/addons')[0]; + this.router.navigate([`${baseUrl}/addons/configure-addon`], { + state: { addon }, + }); + } + } + showDisableDialog(): void { this.isDialogVisible.set(true); } diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts index 810faf37f..378a11261 100644 --- a/src/app/shared/enums/index.ts +++ b/src/app/shared/enums/index.ts @@ -9,7 +9,6 @@ export * from './filter-type.enum'; export * from './profile-addons-stepper.enum'; export * from './resource-tab.enum'; export * from './resource-type.enum'; -export * from './settings-addons-stepper.enum'; export * from './share-indexing.enum'; export * from './sort-order.enum'; export * from './subscriptions'; diff --git a/src/app/shared/enums/profile-addons-stepper.enum.ts b/src/app/shared/enums/profile-addons-stepper.enum.ts index 367d4065e..43ee1793b 100644 --- a/src/app/shared/enums/profile-addons-stepper.enum.ts +++ b/src/app/shared/enums/profile-addons-stepper.enum.ts @@ -2,6 +2,7 @@ export enum ProjectAddonsStepperValue { TERMS = 1, CHOOSE_CONNECTION = 2, CHOOSE_ACCOUNT = 3, - SETUP_NEW_ACCOUNT = 4, - AUTH = 5, + CONFIGURE_ROOT_FOLDER = 4, + SETUP_NEW_ACCOUNT = 5, + AUTH = 6, } diff --git a/src/app/shared/mappers/addon.mapper.ts b/src/app/shared/mappers/addon.mapper.ts index 181bbd176..85b70ae0b 100644 --- a/src/app/shared/mappers/addon.mapper.ts +++ b/src/app/shared/mappers/addon.mapper.ts @@ -1,15 +1,18 @@ import { Addon, - AddonGetResponse, + AddonGetResponseJsonApi, AuthorizedAddon, - AuthorizedAddonGetResponse, + AuthorizedAddonGetResponseJsonApi, ConfiguredAddon, - ConfiguredAddonGetResponse, + ConfiguredAddonGetResponseJsonApi, IncludedAddonData, + OperationInvocation, + OperationInvocationResponseJsonApi, + StorageItemResponse, } from '@shared/models'; export class AddonMapper { - static fromResponse(response: AddonGetResponse): Addon { + static fromResponse(response: AddonGetResponseJsonApi): Addon { return { type: response.type, id: response.id, @@ -23,7 +26,7 @@ export class AddonMapper { } static fromAuthorizedAddonResponse( - response: AuthorizedAddonGetResponse, + response: AuthorizedAddonGetResponseJsonApi, included?: IncludedAddonData[] ): AuthorizedAddon { // Handle both storage and citation service relationships @@ -64,16 +67,53 @@ export class AddonMapper { }; } - static fromConfiguredAddonResponse(response: ConfiguredAddonGetResponse): ConfiguredAddon { + static fromConfiguredAddonResponse(response: ConfiguredAddonGetResponseJsonApi): ConfiguredAddon { return { type: response.type, id: response.id, displayName: response.attributes.display_name, externalServiceName: response.attributes.external_service_name, - rootFolder: response.attributes.root_folder, + selectedFolderId: response.attributes.root_folder, connectedCapabilities: response.attributes.connected_capabilities, connectedOperationNames: response.attributes.connected_operation_names, currentUserIsOwner: response.attributes.current_user_is_owner, + baseAccountId: response.relationships.base_account.data.id, + baseAccountType: response.relationships.base_account.data.type, + }; + } + + static fromOperationInvocationResponse(response: OperationInvocationResponseJsonApi): OperationInvocation { + const operationResult = response.attributes.operation_result; + const isOperationResult = 'items' in operationResult && 'total_count' in operationResult; + + const mappedOperationResult = isOperationResult + ? operationResult.items.map((item: StorageItemResponse) => ({ + itemId: item.item_id, + itemName: item.item_name, + itemType: item.item_type, + canBeRoot: item.can_be_root, + mayContainRootCandidates: item.may_contain_root_candidates, + })) + : [ + { + itemId: operationResult.item_id, + itemName: operationResult.item_name, + itemType: operationResult.item_type, + canBeRoot: operationResult.can_be_root, + mayContainRootCandidates: operationResult.may_contain_root_candidates, + }, + ]; + return { + type: response.type, + id: response.id, + invocationStatus: response.attributes.invocation_status, + operationName: response.attributes.operation_name, + operationKwargs: { + itemId: response.attributes.operation_kwargs.item_id, + itemType: response.attributes.operation_kwargs.item_type, + }, + itemCount: isOperationResult ? operationResult.total_count : 0, + operationResult: mappedOperationResult, }; } } diff --git a/src/app/shared/models/addons/addons.models.ts b/src/app/shared/models/addons/addons.models.ts index 1e3c731e7..a3e1d3288 100644 --- a/src/app/shared/models/addons/addons.models.ts +++ b/src/app/shared/models/addons/addons.models.ts @@ -1,4 +1,4 @@ -export interface AddonGetResponse { +export interface AddonGetResponseJsonApi { type: string; id: string; attributes: { @@ -19,7 +19,7 @@ export interface AddonGetResponse { }; } -export interface AuthorizedAddonGetResponse { +export interface AuthorizedAddonGetResponseJsonApi { type: string; id: string; attributes: { @@ -53,7 +53,7 @@ export interface AuthorizedAddonGetResponse { }; } -export interface ConfiguredAddonGetResponse { +export interface ConfiguredAddonGetResponseJsonApi { type: string; id: string; attributes: { @@ -64,6 +64,14 @@ export interface ConfiguredAddonGetResponse { connected_operation_names: string[]; current_user_is_owner: boolean; }; + relationships: { + base_account: { + data: { + type: string; + id: string; + }; + }; + }; } export interface Addon { @@ -100,10 +108,12 @@ export interface ConfiguredAddon { id: string; displayName: string; externalServiceName: string; - rootFolder: string; + selectedFolderId: string; connectedCapabilities: string[]; connectedOperationNames: string[]; currentUserIsOwner: boolean; + baseAccountId: string; + baseAccountType: string; } export interface IncludedAddonData { @@ -121,7 +131,7 @@ export interface IncludedAddonData { >; } -export interface UserReference { +export interface UserReferenceJsonApi { type: string; id: string; attributes: { @@ -129,7 +139,7 @@ export interface UserReference { }; } -export interface ResourceReference { +export interface ResourceReferenceJsonApi { type: string; id: string; attributes: { @@ -137,7 +147,7 @@ export interface ResourceReference { }; } -export interface AddonRequest { +export interface AuthorizedAddonRequestJsonApi { data: { id?: string; attributes: { @@ -173,7 +183,7 @@ export interface AddonRequest { }; } -export interface AddonResponse { +export interface AuthorizedAddonResponseJsonApi { type: string; id: string; attributes: { @@ -200,3 +210,88 @@ export interface AddonResponse { }; }; } + +export interface ConfiguredAddonRequestJsonApi { + data: { + attributes: { + authorized_resource_uri: string; + display_name: string; + connected_capabilities: string[]; + connected_operation_names: string[]; + root_folder: string; + external_service_name: string; + }; + relationships: { + external_storage_service?: { + data: { + type: string; + id: string; + }; + }; + external_citation_service?: { + data: { + type: string; + id: string; + }; + }; + base_account: { + data: { + type: string; + id: string; + }; + }; + authorized_resource?: { + data: { + type: string; + id: string; + }; + }; + account_owner: { + data: { + type: string; + id: string; + }; + }; + }; + type: string; + id?: string; + }; +} + +export interface ConfiguredAddonResponseJsonApi { + data: { + type: string; + id: string; + attributes: { + display_name: string; + root_folder: string; + connected_capabilities: string[]; + connected_operation_names: string[]; + current_user_is_owner: boolean; + external_service_name: string; + }; + relationships: { + base_account: { + data: { + type: string; + id: string; + }; + }; + authorized_resource: { + data: { + type: string; + id: string; + }; + }; + external_storage_service: { + data: { + type: string; + id: string; + }; + }; + }; + links: { + self: string; + }; + }; +} diff --git a/src/app/shared/models/addons/index.ts b/src/app/shared/models/addons/index.ts index 1b34b1d47..1ba8a3213 100644 --- a/src/app/shared/models/addons/index.ts +++ b/src/app/shared/models/addons/index.ts @@ -1,4 +1,5 @@ export * from './addon-form.model'; export * from './addon-terms.model'; export * from './addons.models'; +export * from './operation-invocation.models'; export * from './term.model'; diff --git a/src/app/shared/models/addons/operation-invocation.models.ts b/src/app/shared/models/addons/operation-invocation.models.ts new file mode 100644 index 000000000..c22faf014 --- /dev/null +++ b/src/app/shared/models/addons/operation-invocation.models.ts @@ -0,0 +1,77 @@ +export interface StorageItemResponse { + item_id?: string; + item_name?: string; + item_type?: string; + can_be_root?: boolean; + may_contain_root_candidates?: boolean; +} + +export interface OperationResult { + items: StorageItemResponse[]; + total_count: number; +} + +export interface OperationInvocationRequestJsonApi { + data: { + attributes: { + invocation_status: string | null; + operation_name: string; + operation_kwargs: Record; + operation_result: Record; + created: string | null; + modified: string | null; + }; + relationships: { + thru_account?: { + data: { + type: string; + id: string; + }; + }; + thru_addon?: { + data: { + type: string; + id: string; + }; + }; + }; + type: string; + }; +} + +export interface OperationInvocationResponseJsonApi { + type: string; + id: string; + attributes: { + invocation_status: string; + operation_kwargs: StorageItemResponse; + operation_result: OperationResult | StorageItemResponse; + created: string; + modified: string; + operation_name: string; + }; + links: { + self: string; + }; +} + +export interface StorageItem { + itemId?: string; + itemName?: string; + itemType?: string; + canBeRoot?: boolean; + mayContainRootCandidates?: boolean; +} + +export interface OperationInvocation { + id: string; + type: string; + invocationStatus: string; + operationName: string; + operationKwargs: { + itemId?: string; + itemType?: string; + }; + operationResult: StorageItem[]; + itemCount: number; +} diff --git a/src/app/shared/services/addons.service.ts b/src/app/shared/services/addons.service.ts index e3ad4c917..3cdf28aa9 100644 --- a/src/app/shared/services/addons.service.ts +++ b/src/app/shared/services/addons.service.ts @@ -10,17 +10,22 @@ import { UserSelectors } from '@core/store/user'; import { AddonMapper } from '@osf/shared/mappers'; import { Addon, - AddonGetResponse, - AddonRequest, - AddonResponse, + AddonGetResponseJsonApi, AuthorizedAddon, - AuthorizedAddonGetResponse, + AuthorizedAddonGetResponseJsonApi, + AuthorizedAddonRequestJsonApi, + AuthorizedAddonResponseJsonApi, ConfiguredAddon, - ConfiguredAddonGetResponse, + ConfiguredAddonGetResponseJsonApi, + ConfiguredAddonRequestJsonApi, + ConfiguredAddonResponseJsonApi, IncludedAddonData, - ResourceReference, - UserReference, + OperationInvocation, + OperationInvocationRequestJsonApi, + ResourceReferenceJsonApi, + UserReferenceJsonApi, } from '@shared/models'; +import { OperationInvocationResponseJsonApi } from '@shared/models/addons/operation-invocation.models'; import { environment } from '../../../environments/environment'; @@ -34,7 +39,9 @@ export class AddonsService { getAddons(addonType: string): Observable { return this.#jsonApiService - .get>(`${environment.addonsApiUrl}/external-${addonType}-services`) + .get< + JsonApiResponse + >(`${environment.addonsApiUrl}/external-${addonType}-services`) .pipe( map((response) => { return response.data.map((item) => AddonMapper.fromResponse(item)); @@ -42,7 +49,7 @@ export class AddonsService { ); } - getAddonsUserReference(): Observable { + getAddonsUserReference(): Observable { const currentUser = this.#currentUser(); if (!currentUser) throw new Error('Current user not found'); @@ -52,18 +59,20 @@ export class AddonsService { }; return this.#jsonApiService - .get>(environment.addonsApiUrl + '/user-references/', params) + .get>(environment.addonsApiUrl + '/user-references/', params) .pipe(map((response) => response.data)); } - getAddonsResourceReference(resourceId: string): Observable { + getAddonsResourceReference(resourceId: string): Observable { const resourceUri = `https://staging4.osf.io/${resourceId}`; const params = { 'filter[resource_uri]': resourceUri, }; return this.#jsonApiService - .get>(environment.addonsApiUrl + '/resource-references/', params) + .get< + JsonApiResponse + >(environment.addonsApiUrl + '/resource-references/', params) .pipe(map((response) => response.data)); } @@ -73,7 +82,7 @@ export class AddonsService { }; return this.#jsonApiService .get< - JsonApiResponse + JsonApiResponse >(`${environment.addonsApiUrl}/user-references/${referenceId}/authorized_${addonType}_accounts/?include=external-${addonType}-service`, params) .pipe( map((response) => { @@ -85,8 +94,8 @@ export class AddonsService { getConfiguredAddons(addonType: string, referenceId: string): Observable { return this.#jsonApiService .get< - JsonApiResponse - >(`${environment.addonsApiUrl}/resource-references/${referenceId}/configured_${addonType}_accounts/`) + JsonApiResponse + >(`${environment.addonsApiUrl}/resource-references/${referenceId}/configured_${addonType}_addons/`) .pipe( map((response) => { return response.data.map((item) => AddonMapper.fromConfiguredAddonResponse(item)); @@ -94,25 +103,68 @@ export class AddonsService { ); } - createAuthorizedAddon(addonRequestPayload: AddonRequest, addonType: string): Observable { - return this.#jsonApiService.post( + createAuthorizedAddon( + addonRequestPayload: AuthorizedAddonRequestJsonApi, + addonType: string + ): Observable { + return this.#jsonApiService.post( `${environment.addonsApiUrl}/authorized-${addonType}-accounts/`, addonRequestPayload ); } updateAuthorizedAddon( - addonRequestPayload: AddonRequest, + addonRequestPayload: AuthorizedAddonRequestJsonApi, addonType: string, addonId: string - ): Observable { - return this.#jsonApiService.patch( + ): Observable { + return this.#jsonApiService.patch( `${environment.addonsApiUrl}/authorized-${addonType}-accounts/${addonId}/`, addonRequestPayload ); } + createConfiguredAddon( + addonRequestPayload: ConfiguredAddonRequestJsonApi, + addonType: string + ): Observable { + return this.#jsonApiService.post( + `${environment.addonsApiUrl}/configured-${addonType}-addons/`, + addonRequestPayload + ); + } + + updateConfiguredAddon( + addonRequestPayload: ConfiguredAddonRequestJsonApi, + addonType: string, + addonId: string + ): Observable { + return this.#jsonApiService.patch( + `${environment.addonsApiUrl}/configured-${addonType}-addons/${addonId}/`, + addonRequestPayload + ); + } + + createAddonOperationInvocation( + invocationRequestPayload: OperationInvocationRequestJsonApi + ): Observable { + return this.#jsonApiService + .post( + `${environment.addonsApiUrl}/addon-operation-invocations/`, + invocationRequestPayload + ) + .pipe( + map((response) => { + return AddonMapper.fromOperationInvocationResponse(response); + }) + ); + } + deleteAuthorizedAddon(id: string, addonType: string): Observable { return this.#jsonApiService.delete(`${environment.addonsApiUrl}/authorized-${addonType}-accounts/${id}/`); } + + deleteConfiguredAddon(id: string, addonType: string): Observable { + return this.#jsonApiService.delete(`${environment.addonsApiUrl}/${addonType}/${id}/`); + } } diff --git a/src/app/shared/stores/addons/addons.actions.ts b/src/app/shared/stores/addons/addons.actions.ts index 237a61cad..880db37c3 100644 --- a/src/app/shared/stores/addons/addons.actions.ts +++ b/src/app/shared/stores/addons/addons.actions.ts @@ -1,4 +1,8 @@ -import { AddonRequest } from '@shared/models'; +import { + AuthorizedAddonRequestJsonApi, + ConfiguredAddonRequestJsonApi, + OperationInvocationRequestJsonApi, +} from '@shared/models'; export class GetStorageAddons { static readonly type = '[Addons] Get Storage Addons'; @@ -33,19 +37,38 @@ export class GetConfiguredCitationAddons { } export class CreateAuthorizedAddon { - static readonly type = '[Addons] Create Storage Addon'; + static readonly type = '[Addons] Create Authorized Addon'; constructor( - public payload: AddonRequest, + public payload: AuthorizedAddonRequestJsonApi, public addonType: string ) {} } export class UpdateAuthorizedAddon { - static readonly type = '[Addons] Update Storage Addon'; + static readonly type = '[Addons] Update Authorized Addon'; constructor( - public payload: AddonRequest, + public payload: AuthorizedAddonRequestJsonApi, + public addonType: string, + public addonId: string + ) {} +} + +export class CreateConfiguredAddon { + static readonly type = '[Addons] Create Configured Addon'; + + constructor( + public payload: ConfiguredAddonRequestJsonApi, + public addonType: string + ) {} +} + +export class UpdateConfiguredAddon { + static readonly type = '[Addons] Update Configured Addon'; + + constructor( + public payload: ConfiguredAddonRequestJsonApi, public addonType: string, public addonId: string ) {} @@ -65,7 +88,26 @@ export class DeleteAuthorizedAddon { static readonly type = '[Addons] Delete Authorized Addon'; constructor( - public payload: string, + public id: string, + public addonType: string + ) {} +} + +export class DeleteConfiguredAddon { + static readonly type = '[Addons] Delete Configured Addon'; + + constructor( + public id: string, public addonType: string ) {} } + +export class CreateAddonOperationInvocation { + static readonly type = '[Addons] Create Addon Operation Invocation'; + + constructor(public payload: OperationInvocationRequestJsonApi) {} +} + +export class ClearConfiguredAddons { + static readonly type = '[Addons] Clear Configured Addons'; +} diff --git a/src/app/shared/stores/addons/addons.models.ts b/src/app/shared/stores/addons/addons.models.ts index 7f2fa30ed..3389bd3c2 100644 --- a/src/app/shared/stores/addons/addons.models.ts +++ b/src/app/shared/stores/addons/addons.models.ts @@ -1,10 +1,12 @@ import { Addon, - AddonResponse, AuthorizedAddon, + AuthorizedAddonResponseJsonApi, ConfiguredAddon, - ResourceReference, - UserReference, + ConfiguredAddonResponseJsonApi, + OperationInvocation, + ResourceReferenceJsonApi, + UserReferenceJsonApi, } from '@shared/models'; import { AsyncStateModel } from '@shared/models/store'; @@ -15,7 +17,10 @@ export interface AddonsStateModel { authorizedCitationAddons: AsyncStateModel; configuredStorageAddons: AsyncStateModel; configuredCitationAddons: AsyncStateModel; - addonsUserReference: AsyncStateModel; - addonsResourceReference: AsyncStateModel; - createdUpdatedAuthorizedAddon: AsyncStateModel; + addonsUserReference: AsyncStateModel; + addonsResourceReference: AsyncStateModel; + createdUpdatedAuthorizedAddon: AsyncStateModel; + createdUpdatedConfiguredAddon: AsyncStateModel; + operationInvocation: AsyncStateModel; + selectedFolderOperationInvocation: AsyncStateModel; } diff --git a/src/app/shared/stores/addons/addons.selectors.ts b/src/app/shared/stores/addons/addons.selectors.ts index 5a911880e..b86c9588a 100644 --- a/src/app/shared/stores/addons/addons.selectors.ts +++ b/src/app/shared/stores/addons/addons.selectors.ts @@ -2,11 +2,13 @@ import { Selector } from '@ngxs/store'; import { Addon, - AddonResponse, AuthorizedAddon, + AuthorizedAddonResponseJsonApi, ConfiguredAddon, - ResourceReference, - UserReference, + ConfiguredAddonResponseJsonApi, + OperationInvocation, + ResourceReferenceJsonApi, + UserReferenceJsonApi, } from '@shared/models'; import { AddonsStateModel } from './addons.models'; @@ -74,7 +76,7 @@ export class AddonsSelectors { } @Selector([AddonsState]) - static getAddonsUserReference(state: AddonsStateModel): UserReference[] { + static getAddonsUserReference(state: AddonsStateModel): UserReferenceJsonApi[] { return state.addonsUserReference.data; } @@ -84,7 +86,7 @@ export class AddonsSelectors { } @Selector([AddonsState]) - static getAddonsResourceReference(state: AddonsStateModel): ResourceReference[] { + static getAddonsResourceReference(state: AddonsStateModel): ResourceReferenceJsonApi[] { return state.addonsResourceReference.data; } @@ -94,7 +96,7 @@ export class AddonsSelectors { } @Selector([AddonsState]) - static getCreatedOrUpdatedAuthorizedAddon(state: AddonsStateModel): AddonResponse | null { + static getCreatedOrUpdatedAuthorizedAddon(state: AddonsStateModel): AuthorizedAddonResponseJsonApi | null { return state.createdUpdatedAuthorizedAddon.data; } @@ -102,4 +104,39 @@ export class AddonsSelectors { static getCreatedOrUpdatedStorageAddonSubmitting(state: AddonsStateModel): boolean { return state.createdUpdatedAuthorizedAddon.isSubmitting || false; } + + @Selector([AddonsState]) + static getCreatedOrUpdatedConfiguredAddon(state: AddonsStateModel): ConfiguredAddonResponseJsonApi | null { + return state.createdUpdatedConfiguredAddon.data; + } + + @Selector([AddonsState]) + static getCreatedOrUpdatedConfiguredAddonSubmitting(state: AddonsStateModel): boolean { + return state.createdUpdatedConfiguredAddon.isSubmitting || false; + } + + @Selector([AddonsState]) + static getOperationInvocation(state: AddonsStateModel): OperationInvocation | null { + return state.operationInvocation.data; + } + + @Selector([AddonsState]) + static getOperationInvocationSubmitting(state: AddonsStateModel): boolean { + return state.operationInvocation.isSubmitting || false; + } + + @Selector([AddonsState]) + static getSelectedFolderOperationInvocation(state: AddonsStateModel): OperationInvocation | null { + return state.selectedFolderOperationInvocation.data; + } + + @Selector([AddonsState]) + static getSelectedFolderName(state: AddonsStateModel): string { + return state.selectedFolderOperationInvocation.data?.operationResult[0].itemName || ''; + } + + @Selector([AddonsState]) + static getDeleteStorageAddonSubmitting(state: AddonsStateModel): boolean { + return state.createdUpdatedConfiguredAddon.isSubmitting || false; + } } diff --git a/src/app/shared/stores/addons/addons.state.ts b/src/app/shared/stores/addons/addons.state.ts index d29189383..bbd072aea 100644 --- a/src/app/shared/stores/addons/addons.state.ts +++ b/src/app/shared/stores/addons/addons.state.ts @@ -7,8 +7,12 @@ import { inject, Injectable } from '@angular/core'; import { AddonsService } from '@shared/services'; import { + ClearConfiguredAddons, + CreateAddonOperationInvocation, CreateAuthorizedAddon, + CreateConfiguredAddon, DeleteAuthorizedAddon, + DeleteConfiguredAddon, GetAddonsResourceReference, GetAddonsUserReference, GetAuthorizedCitationAddons, @@ -18,6 +22,7 @@ import { GetConfiguredStorageAddons, GetStorageAddons, UpdateAuthorizedAddon, + UpdateConfiguredAddon, } from './addons.actions'; import { AddonsStateModel } from './addons.models'; @@ -70,6 +75,24 @@ const ADDONS_DEFAULTS: AddonsStateModel = { isSubmitting: false, error: null, }, + createdUpdatedConfiguredAddon: { + data: null, + isLoading: false, + isSubmitting: false, + error: null, + }, + operationInvocation: { + data: null, + isLoading: false, + isSubmitting: false, + error: null, + }, + selectedFolderOperationInvocation: { + data: null, + isLoading: false, + isSubmitting: false, + error: null, + }, }; @State({ @@ -290,6 +313,39 @@ export class AddonsState { ); } + @Action(CreateConfiguredAddon) + createConfiguredAddon(ctx: StateContext, action: CreateConfiguredAddon) { + const state = ctx.getState(); + ctx.patchState({ + createdUpdatedConfiguredAddon: { + ...state.createdUpdatedConfiguredAddon, + isSubmitting: true, + }, + }); + + return this.addonsService.createConfiguredAddon(action.payload, action.addonType).pipe( + tap((addon) => { + ctx.patchState({ + createdUpdatedConfiguredAddon: { + data: addon, + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + // const referenceId = state.addonsResourceReference.data[0]?.id; + // if (referenceId) { + // ctx.dispatch( + // action.addonType === 'storage' + // ? new GetConfiguredStorageAddons(referenceId) + // : new GetConfiguredCitationAddons(referenceId) + // ); + // } + }), + catchError((error) => this.handleError(ctx, 'createdUpdatedConfiguredAddon', error)) + ); + } + @Action(GetAddonsUserReference) getAddonsUserReference(ctx: StateContext) { const state = ctx.getState(); @@ -314,6 +370,45 @@ export class AddonsState { ); } + @Action(UpdateConfiguredAddon) + updateConfiguredAddon(ctx: StateContext, action: UpdateConfiguredAddon) { + const state = ctx.getState(); + ctx.patchState({ + createdUpdatedConfiguredAddon: { + ...state.createdUpdatedConfiguredAddon, + isSubmitting: true, + }, + }); + + return this.addonsService.updateConfiguredAddon(action.payload, action.addonType, action.addonId).pipe( + tap((addon) => { + ctx.patchState({ + createdUpdatedConfiguredAddon: { + data: addon, + isLoading: false, + isSubmitting: false, + error: null, + }, + selectedFolderOperationInvocation: { + data: null, + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + const referenceId = state.addonsResourceReference.data[0]?.id; + if (referenceId) { + ctx.dispatch( + action.addonType === 'storage' + ? new GetConfiguredStorageAddons(referenceId) + : new GetConfiguredCitationAddons(referenceId) + ); + } + }), + catchError((error) => this.handleError(ctx, 'createdUpdatedAuthorizedAddon', error)) + ); + } + @Action(GetAddonsResourceReference) getAddonsResourceReference(ctx: StateContext, action: GetAddonsResourceReference) { const state = ctx.getState(); @@ -349,7 +444,7 @@ export class AddonsState { }, }); - return this.addonsService.deleteAuthorizedAddon(action.payload, action.addonType).pipe( + return this.addonsService.deleteAuthorizedAddon(action.id, action.addonType).pipe( switchMap(() => { const referenceId = state.addonsUserReference.data[0]?.id; if (referenceId) { @@ -363,11 +458,98 @@ export class AddonsState { ); } - private handleError(ctx: StateContext, section: keyof AddonsStateModel, error: Error) { + @Action(DeleteConfiguredAddon) + deleteConfiguredAddon(ctx: StateContext, action: DeleteConfiguredAddon) { + const state = ctx.getState(); + + ctx.patchState({ + createdUpdatedConfiguredAddon: { + ...state.createdUpdatedConfiguredAddon, + isSubmitting: true, + }, + }); + + return this.addonsService.deleteConfiguredAddon(action.id, action.addonType).pipe( + switchMap(() => { + ctx.patchState({ + createdUpdatedConfiguredAddon: { + ...state.createdUpdatedConfiguredAddon, + isSubmitting: false, + }, + }); + const referenceId = state.addonsResourceReference.data[0]?.id; + if (referenceId) { + return action.addonType === 'configured-storage-addons' + ? ctx.dispatch(new GetConfiguredStorageAddons(referenceId)) + : ctx.dispatch(new GetConfiguredCitationAddons(referenceId)); + } + return []; + }), + catchError((error) => this.handleError(ctx, 'createdUpdatedConfiguredAddon', error)) + ); + } + + @Action(CreateAddonOperationInvocation) + createAddonOperationInvocation(ctx: StateContext, action: CreateAddonOperationInvocation) { const state = ctx.getState(); + ctx.patchState({ + operationInvocation: { + ...state.operationInvocation, + isSubmitting: true, + }, + }); + + return this.addonsService.createAddonOperationInvocation(action.payload).pipe( + tap((response) => { + ctx.patchState({ + operationInvocation: { + data: response, + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + + if (response.operationName === 'get_item_info' && response.operationResult[0]?.itemName) { + ctx.patchState({ + selectedFolderOperationInvocation: { + data: response, + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + } + }), + catchError((error) => this.handleError(ctx, 'operationInvocation', error)) + ); + } + + @Action(ClearConfiguredAddons) + clearConfiguredAddons(ctx: StateContext) { + ctx.patchState({ + configuredStorageAddons: { + data: [], + isLoading: false, + error: null, + }, + configuredCitationAddons: { + data: [], + isLoading: false, + error: null, + }, + addonsResourceReference: { + data: [], + isLoading: false, + error: null, + }, + }); + } + + private handleError(ctx: StateContext, section: keyof AddonsStateModel, error: Error) { ctx.patchState({ [section]: { - ...state[section], + ...ctx.getState()[section], isLoading: false, isSubmitting: false, error: error.message, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 64d71f96a..6ab5e7a9f 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -10,7 +10,9 @@ "download": "Download", "copy": "Copy", "move": "Move", - "rename": "Rename" + "rename": "Rename", + "confirm": "Confirm", + "disconnect": "Disconnect" }, "search": { "noResultsFound": "No results found." @@ -567,69 +569,6 @@ "openResources": "Open Resources" } }, - "files": { - "title": "Files", - "storageLocation": "OSF Storage", - "searchPlaceholder": "Search your projects", - "sort": { - "placeholder": "Sort", - "nameAZ": "Name: A-Z", - "nameZA": "Name: Z-A", - "lastModifiedOldest": "Last modified: oldest to newest", - "lastModifiedNewest": "Last modified: newest to oldest" - }, - "actions": { - "downloadAsZip": "Download As Zip", - "createFolder": "Create Folder", - "uploadFile": "Upload File" - }, - "dialogs": { - "uploadFile": { - "title": "Upload file" - }, - "createFolder": { - "title": "Create folder", - "folderName": "New folder name", - "folderNamePlaceholder": "Please enter a folder name" - }, - "renameFile": { - "title": "Rename file", - "renameLabel": "Please rename the file" - }, - "moveFile": "Cannot move to the same folder" - }, - "emptyState": "This folder is empty", - "detail": { - "backToList": "Back to list of files", - "fileMetadata": { - "title": "File Metadata", - "edit": "Edit", - "fields": { - "title": "Title", - "description": "Description", - "resourceType": "Resource Type", - "resourceLanguage": "Resource Language" - } - }, - "projectMetadata": { - "title": "Project Metadata", - "edit": "Edit", - "fields": { - "funder": "Funder", - "awardTitle": "Award title", - "awardNumber": "Award number", - "awardUri": "Award URI", - "title": "Title", - "description": "Description", - "resourceType": "Resource type", - "resourceLanguage": "Resource language", - "dateCreated": "Date created", - "dateModified": "Date modified", - "contributors": "Contributors" - } - } - } - }, "files": { "title": "Files", "storageLocation": "OSF Storage", @@ -922,6 +861,16 @@ "additionalService": "Additional Storage", "citationManager": "Citation Manager" }, + "configureAddon": { + "title": "Configure Add-on", + "noFolders": "No folders", + "disconnect": "Disconnect {{addonName}}", + "disconnectMessage": "You are about to disconnect the following addon from the project:", + "account": "Account:", + "selectedFolder": "Selected folder:", + "connectedAccount": "Connected to account:", + "home": "Home" + }, "connectAddon": { "title": "Connect Add-on", "terms": "Terms", @@ -929,7 +878,9 @@ "function": "Function", "status": "Status" }, - "configure": "Configure ", + "confirmAccount": "Confirm account", + "connectAccount": "Connect following account: {{accountName}}", + "configure": "Configure", "termsDescription": "• This add-on connects your OSF project to an external service. Use of this service is bound by its terms and conditions. The OSF is not responsible for the service or for your use thereof.", "storageDescription": "• This add-on allows you to store files using an external service. Files added to this add-on are not stored within the OSF.", "reconnectAccount": "Reconnect Account", diff --git a/src/assets/styles/components/addons.scss b/src/assets/styles/components/addons.scss new file mode 100644 index 000000000..d39e5cd83 --- /dev/null +++ b/src/assets/styles/components/addons.scss @@ -0,0 +1,46 @@ +@use "assets/styles/mixins" as mix; +@use "assets/styles/variables" as var; + +.p-breadcrumb { + padding: 0 0 mix.rem(16px) 0; +} + +.folders-table { + min-width: 100%; + border-radius: mix.rem(8px); + overflow-x: auto; + border: 1px solid var.$grey-2; + + &-heading { + height: mix.rem(44px); + padding: 0 mix.rem(27px); + align-items: center; + border-bottom: 1px solid var.$grey-2; + } + + &-row { + display: flex; + align-items: center; + justify-content: space-between; + height: mix.rem(44px); + border-bottom: 1px solid var.$grey-2; + padding: 0 mix.rem(27px); + min-width: max-content; + + .table-cell { + width: 100%; + height: 100%; + display: flex; + align-items: center; + } + + > .table-cell:first-child { + min-width: 0; + max-width: 95%; + } + } + + &-row:last-child { + border-bottom: none; + } +} diff --git a/src/assets/styles/styles.scss b/src/assets/styles/styles.scss index 3b09e0b52..ca0da7720 100644 --- a/src/assets/styles/styles.scss +++ b/src/assets/styles/styles.scss @@ -39,3 +39,4 @@ @use "./overrides/primeflex-override"; @use "./overrides/multiselect"; @use "components/preprints"; +@use "components/addons"; From 3484429fbfc5723f715eff2eb48fd3d0f232a3cd Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Wed, 18 Jun 2025 15:56:02 +0300 Subject: [PATCH 06/18] feat(profile-addons-api): fixed connection creation bug, fixed form, refactored code --- .../project/addons/addons.component.html | 14 +- .../project/addons/addons.component.scss | 1 - .../project/addons/addons.component.ts | 7 +- .../configure-addon.component.html | 132 +------ .../configure-addon.component.scss | 5 +- .../configure-addon.component.ts | 226 ++++------- ...firm-account-connection-modal.component.ts | 29 +- .../connect-configured-addon.component.html | 232 ++--------- .../connect-configured-addon.component.ts | 368 ++++++------------ .../disconnect-addon-modal.component.html | 2 +- .../disconnect-addon-modal.component.ts | 2 +- .../features/project/addons/services/index.ts | 2 + .../features/project/addons/utils/index.ts | 1 + .../addons/utils/resource-base-uri.const.ts | 1 + .../settings/addons/addons.component.ts | 2 - .../connect-addon.component.html | 287 +------------- .../connect-addon/connect-addon.component.ts | 233 +++-------- .../addon-card-list.component.html | 2 +- .../addon-card/addon-card.component.html | 12 +- .../addon-setup-account-form.component.html | 133 +++++++ .../addon-setup-account-form.component.scss | 0 .../addon-setup-account-form.component.ts | 82 ++++ .../addon-terms/addon-terms.component.html | 24 ++ .../addon-terms/addon-terms.component.scss | 0 .../addon-terms/addon-terms.component.ts | 68 ++++ .../folder-selector.component.html | 110 ++++++ .../folder-selector.component.scss} | 8 +- .../folder-selector.component.ts | 150 +++++++ src/app/shared/components/addons/index.ts | 3 + .../shared/enums/addon-form-controls.enum.ts | 1 + .../shared/models/addons/addon-form.model.ts | 1 + src/app/shared/models/addons/index.ts | 1 + .../addons/operation-invoke-data.model.ts | 6 + .../services/addons/addon-dialog.service.ts | 55 +++ .../services/addons/addon-form.service.ts | 202 ++++++++++ .../addon-operation-invocation.service.ts | 80 ++++ .../services/{ => addons}/addons.service.ts | 4 +- src/app/shared/services/addons/index.ts | 4 + src/app/shared/services/index.ts | 2 +- .../shared/stores/addons/addons.actions.ts | 4 + .../shared/stores/addons/addons.selectors.ts | 5 +- src/app/shared/stores/addons/addons.state.ts | 17 + src/assets/i18n/en.json | 7 + src/assets/styles/overrides/breadcrumbs.scss | 12 + src/assets/styles/styles.scss | 2 +- src/main.ts | 4 +- 46 files changed, 1284 insertions(+), 1259 deletions(-) create mode 100644 src/app/features/project/addons/services/index.ts create mode 100644 src/app/features/project/addons/utils/resource-base-uri.const.ts create mode 100644 src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.html create mode 100644 src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.scss create mode 100644 src/app/shared/components/addons/addon-setup-account-form/addon-setup-account-form.component.ts create mode 100644 src/app/shared/components/addons/addon-terms/addon-terms.component.html create mode 100644 src/app/shared/components/addons/addon-terms/addon-terms.component.scss create mode 100644 src/app/shared/components/addons/addon-terms/addon-terms.component.ts create mode 100644 src/app/shared/components/addons/folder-selector/folder-selector.component.html rename src/{assets/styles/components/addons.scss => app/shared/components/addons/folder-selector/folder-selector.component.scss} (93%) create mode 100644 src/app/shared/components/addons/folder-selector/folder-selector.component.ts create mode 100644 src/app/shared/models/addons/operation-invoke-data.model.ts create mode 100644 src/app/shared/services/addons/addon-dialog.service.ts create mode 100644 src/app/shared/services/addons/addon-form.service.ts create mode 100644 src/app/shared/services/addons/addon-operation-invocation.service.ts rename src/app/shared/services/{ => addons}/addons.service.ts (98%) create mode 100644 src/app/shared/services/addons/index.ts create mode 100644 src/assets/styles/overrides/breadcrumbs.scss diff --git a/src/app/features/project/addons/addons.component.html b/src/app/features/project/addons/addons.component.html index cf196f066..36af5b43e 100644 --- a/src/app/features/project/addons/addons.component.html +++ b/src/app/features/project/addons/addons.component.html @@ -1,7 +1,7 @@
-