diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index e9a6376884..fe2837c6e3 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -126,3 +126,9 @@ export function getRequestCopyModulePath() { } export const HEALTH_PAGE_PATH = 'health'; + +export const SUBSCRIPTIONS_MODULE_PATH = 'subscriptions'; + +export function getSubscriptionsModuleRoute() { + return `/${SUBSCRIPTIONS_MODULE_PATH}`; +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index d426b041ce..9779c2ab27 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -230,6 +230,12 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone loadChildren: () => import('./access-control/access-control.module').then((m) => m.AccessControlModule), canActivate: [GroupAdministratorGuard], }, + { + path: 'subscriptions', + loadChildren: () => import('./subscriptions-page/subscriptions-page-routing.module') + .then((m) => m.SubscriptionsPageRoutingModule), + canActivate: [AuthenticatedGuard] + }, { path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent }, ] } diff --git a/src/app/collection-page/collection-page.component.html b/src/app/collection-page/collection-page.component.html index 0376b5ddbc..b31ddc50fc 100644 --- a/src/app/collection-page/collection-page.component.html +++ b/src/app/collection-page/collection-page.component.html @@ -33,7 +33,10 @@ [title]="'collection.page.news'"> - + +
+ +
diff --git a/src/app/community-page/community-page.component.html b/src/app/community-page/community-page.component.html index 058ab4c1c1..8e06fd2db3 100644 --- a/src/app/community-page/community-page.component.html +++ b/src/app/community-page/community-page.component.html @@ -21,6 +21,9 @@ +
+ +
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 1a5c1df2dc..0237a9eb53 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -174,6 +174,7 @@ import { OrcidAuthService } from './orcid/orcid-auth.service'; import { VocabularyDataService } from './submission/vocabularies/vocabulary.data.service'; import { VocabularyEntryDetailsDataService } from './submission/vocabularies/vocabulary-entry-details.data.service'; import { IdentifierData } from '../shared/object-list/identifier-data/identifier-data.model'; +import { Subscription } from '../shared/subscriptions/models/subscription.model'; import { SupervisionOrderDataService } from './supervision-order/supervision-order-data.service'; /** @@ -367,6 +368,7 @@ export const models = OrcidHistory, AccessStatusObject, IdentifierData, + Subscription, ]; @NgModule({ diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index d1692b5f0f..8fef45a953 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -33,4 +33,5 @@ export enum FeatureID { CanSubmit = 'canSubmit', CanEditItem = 'canEditItem', CanRegisterDOI = 'canRegisterDOI', + CanSubscribe = 'canSubscribeDso', } diff --git a/src/app/my-dspace-page/my-dspace-search.module.ts b/src/app/my-dspace-page/my-dspace-search.module.ts index 8b03bffea7..f3775214d5 100644 --- a/src/app/my-dspace-page/my-dspace-search.module.ts +++ b/src/app/my-dspace-page/my-dspace-search.module.ts @@ -18,6 +18,7 @@ import { ClaimedApprovedSearchResultListElementComponent } from '../shared/objec import { ClaimedDeclinedSearchResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component'; import { ResearchEntitiesModule } from '../entity-groups/research-entities/research-entities.module'; import { ItemSubmitterComponent } from '../shared/object-collection/shared/mydspace-item-submitter/item-submitter.component'; +import { ItemCollectionComponent } from '../shared/object-collection/shared/mydspace-item-collection/item-collection.component'; import { ItemDetailPreviewComponent } from '../shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component'; import { ItemDetailPreviewFieldComponent } from '../shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component'; import { ItemListPreviewComponent } from '../shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component'; @@ -46,6 +47,7 @@ const ENTRY_COMPONENTS = [ const DECLARATIONS = [ ...ENTRY_COMPONENTS, ItemSubmitterComponent, + ItemCollectionComponent, ItemDetailPreviewComponent, ItemDetailPreviewFieldComponent, ItemListPreviewComponent, diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html index e730b0d85c..5643f3b9a8 100644 --- a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html @@ -6,6 +6,8 @@ {{'nav.profile' | translate}} {{'nav.mydspace' | translate}} + {{'nav.subscriptions' | translate}} + diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts index 22b076c31a..114c711a9b 100644 --- a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts @@ -8,7 +8,7 @@ import { AppState } from '../../../app.reducer'; import { isAuthenticationLoading } from '../../../core/auth/selectors'; import { MYDSPACE_ROUTE } from '../../../my-dspace-page/my-dspace-page.component'; import { AuthService } from '../../../core/auth/auth.service'; -import { getProfileModuleRoute } from '../../../app-routing-paths'; +import { getProfileModuleRoute, getSubscriptionsModuleRoute } from '../../../app-routing-paths'; /** * This component represents the user nav menu. @@ -48,6 +48,11 @@ export class UserMenuComponent implements OnInit { */ public profileRoute = getProfileModuleRoute(); + /** + * The profile page route + */ + public subscriptionsRoute = getSubscriptionsModuleRoute(); + constructor(private store: Store, private authService: AuthService) { } diff --git a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.html b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.html new file mode 100644 index 0000000000..15135009fc --- /dev/null +++ b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.scss b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.spec.ts b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.spec.ts new file mode 100644 index 0000000000..726854778d --- /dev/null +++ b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.spec.ts @@ -0,0 +1,83 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DsoPageSubscriptionButtonComponent } from './dso-page-subscription-button.component'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { of as observableOf } from 'rxjs'; +import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { ITEM } from '../../../core/shared/item.resource-type'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; + +describe('DsoPageSubscriptionButtonComponent', () => { + let component: DsoPageSubscriptionButtonComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + const authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: jasmine.createSpy('isAuthorized') // observableOf(true) + }); + + const mockItem = Object.assign(new Item(), { + id: 'fake-id', + uuid: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + type: ITEM, + _links: { + self: { + href: 'https://localhost:8000/items/fake-id' + } + } + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + NgbModalModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [ DsoPageSubscriptionButtonComponent ], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService }, + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DsoPageSubscriptionButtonComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + component.dso = mockItem; + }); + + describe('when is authorized', () => { + beforeEach(() => { + authorizationService.isAuthorized.and.returnValue(observableOf(true)); + fixture.detectChanges(); + }); + + it('should display subscription button', () => { + expect(de.query(By.css(' [data-test="subscription-button"]'))).toBeTruthy(); + }); + }); + + describe('when is not authorized', () => { + beforeEach(() => { + authorizationService.isAuthorized.and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + + it('should not display subscription button', () => { + expect(de.query(By.css(' [data-test="subscription-button"]'))).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.ts b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.ts new file mode 100644 index 0000000000..54cd9e6bb0 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component.ts @@ -0,0 +1,57 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { Observable, of } from 'rxjs'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; + +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { SubscriptionModalComponent } from '../../subscriptions/subscription-modal/subscription-modal.component'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; + +@Component({ + selector: 'ds-dso-page-subscription-button', + templateUrl: './dso-page-subscription-button.component.html', + styleUrls: ['./dso-page-subscription-button.component.scss'] +}) +/** + * Display a button that opens the modal to manage subscriptions + */ +export class DsoPageSubscriptionButtonComponent implements OnInit { + + /** + * Whether the current user is authorized to edit the DSpaceObject + */ + isAuthorized$: Observable = of(false); + + /** + * Reference to NgbModal + */ + public modalRef: NgbModalRef; + + /** + * DSpaceObject that is being viewed + */ + @Input() dso: DSpaceObject; + + constructor( + protected authorizationService: AuthorizationDataService, + private modalService: NgbModal, + ) { + } + + /** + * check if the current DSpaceObject can be subscribed by the user + */ + ngOnInit(): void { + this.isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanSubscribe, this.dso.self); + } + + /** + * Open the modal to subscribe to the related DSpaceObject + */ + public openSubscriptionModal() { + this.modalRef = this.modalService.open(SubscriptionModalComponent); + this.modalRef.componentInstance.dso = this.dso; + } + +} diff --git a/src/app/shared/object-collection/shared/mydspace-item-collection/item-collection.component.html b/src/app/shared/object-collection/shared/mydspace-item-collection/item-collection.component.html new file mode 100644 index 0000000000..e17ba92a05 --- /dev/null +++ b/src/app/shared/object-collection/shared/mydspace-item-collection/item-collection.component.html @@ -0,0 +1,7 @@ +
+ {{'collection.listelement.badge' | translate}}: + + {{(collection$ | async)?.name}} + + +
\ No newline at end of file diff --git a/src/app/shared/object-collection/shared/mydspace-item-collection/item-collection.component.scss b/src/app/shared/object-collection/shared/mydspace-item-collection/item-collection.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/object-collection/shared/mydspace-item-collection/item-collection.component.ts b/src/app/shared/object-collection/shared/mydspace-item-collection/item-collection.component.ts new file mode 100644 index 0000000000..2f2a20ede3 --- /dev/null +++ b/src/app/shared/object-collection/shared/mydspace-item-collection/item-collection.component.ts @@ -0,0 +1,67 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { EMPTY, Observable } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; + +import { RemoteData } from '../../../../core/data/remote-data'; +import { isNotEmpty } from '../../../empty.util'; +import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; +import { Collection } from '../../../../core/shared/collection.model'; +import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; +import { LinkService } from '../../../../core/cache/builders/link.service'; +import { followLink } from '../../../utils/follow-link-config.model'; + +/** + * This component represents a badge with collection information. + */ +@Component({ + selector: 'ds-item-collection', + styleUrls: ['./item-collection.component.scss'], + templateUrl: './item-collection.component.html' +}) +export class ItemCollectionComponent implements OnInit { + + /** + * The target object + */ + @Input() object: any; + + /** + * The collection object + */ + collection$: Observable; + + public constructor(protected linkService: LinkService) { + + } + + /** + * Initialize collection object + */ + ngOnInit() { + + this.linkService.resolveLinks(this.object, followLink('workflowitem', { + isOptional: true + }, + followLink('collection',{}) + )); + this.collection$ = (this.object.workflowitem as Observable>).pipe( + getFirstCompletedRemoteData(), + mergeMap((rd: RemoteData) => { + if (rd.hasSucceeded && isNotEmpty(rd.payload)) { + return (rd.payload.collection as Observable>).pipe( + getFirstCompletedRemoteData(), + map((rds: RemoteData) => { + if (rds.hasSucceeded && isNotEmpty(rds.payload)) { + return rds.payload; + } else { + return null; + } + }) + ); + } else { + return EMPTY; + } + })); + } +} diff --git a/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.html b/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.html index 0f7ae433fa..db38f98b04 100644 --- a/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.html +++ b/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.html @@ -1,3 +1,3 @@
- {{'submission.workflow.tasks.generic.submitter' | translate}} : {{(submitter$ | async)?.name}} + {{'submission.workflow.tasks.generic.submitter' | translate}}: {{(submitter$ | async)?.name}}
diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html index 5e98b00926..4584b12550 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html @@ -2,6 +2,7 @@
diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html index d2db0ba209..94426136b5 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html @@ -15,34 +15,36 @@

- - - ( - + + ( + ) - - {{'mydspace.results.no-authors' | translate}} - - - ; + {{'mydspace.results.no-authors' + | translate}} + + + ; + + + - - - -
+
- + \ No newline at end of file diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts index 6b40678ded..39f83bc371 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts @@ -8,6 +8,7 @@ import { import { SearchResult } from '../../../search/models/search-result.model'; import { APP_CONFIG, AppConfig } from '../../../../../config/app-config.interface'; import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { WorkflowItem } from 'src/app/core/submission/models/workflowitem.model'; /** * This component show metadata for the given item object in the list view. @@ -40,6 +41,11 @@ export class ItemListPreviewComponent implements OnInit { */ @Input() showSubmitter = false; + /** + * Represents the workflow of the item + */ + @Input() workflowItem: WorkflowItem; + /** * Display thumbnails if required by configuration */ diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/themed-item-list-preview.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/themed-item-list-preview.component.ts index 3fe825d236..ea5a38e3cb 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/themed-item-list-preview.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/themed-item-list-preview.component.ts @@ -1,9 +1,11 @@ -import { Component, Input } from '@angular/core'; +import { ChangeDetectorRef, Component, ComponentFactoryResolver, Input } from '@angular/core'; import { ThemedComponent } from '../../../theme-support/themed.component'; import { ItemListPreviewComponent } from './item-list-preview.component'; import { Item } from '../../../../core/shared/item.model'; import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; import { SearchResult } from '../../../search/models/search-result.model'; +import { WorkflowItem } from 'src/app/core/submission/models/workflowitem.model'; +import { ThemeService } from 'src/app/shared/theme-support/theme.service'; /** * Themed wrapper for ItemListPreviewComponent @@ -11,10 +13,10 @@ import { SearchResult } from '../../../search/models/search-result.model'; @Component({ selector: 'ds-themed-item-list-preview', styleUrls: [], - templateUrl: '../../../theme-support/themed.component.html', + templateUrl: '../../../theme-support/themed.component.html' }) export class ThemedItemListPreviewComponent extends ThemedComponent { - protected inAndOutputNames: (keyof ItemListPreviewComponent & keyof this)[] = ['item', 'object', 'status', 'showSubmitter']; + protected inAndOutputNames: (keyof ItemListPreviewComponent & keyof this)[] = ['item', 'object', 'status', 'showSubmitter', 'workflowItem']; @Input() item: Item; @@ -24,6 +26,19 @@ export class ThemedItemListPreviewComponent extends ThemedComponent
diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 751075ae5f..be36ec9621 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -256,9 +256,10 @@ import { import { AdvancedClaimedTaskActionRatingComponent } from './mydspace-actions/claimed-task/rating/advanced-claimed-task-action-rating.component'; +import { ClaimedTaskActionsDeclineTaskComponent } from './mydspace-actions/claimed-task/decline-task/claimed-task-actions-decline-task.component'; import { - ClaimedTaskActionsDeclineTaskComponent -} from './mydspace-actions/claimed-task/decline-task/claimed-task-actions-decline-task.component'; + DsoPageSubscriptionButtonComponent +} from './dso-page/dso-page-subscription-button/dso-page-subscription-button.component'; import { EpersonGroupListComponent } from './eperson-group-list/eperson-group-list.component'; import { EpersonSearchBoxComponent } from './eperson-group-list/eperson-search-box/eperson-search-box.component'; import { GroupSearchBoxComponent } from './eperson-group-list/group-search-box/group-search-box.component'; @@ -361,6 +362,7 @@ const COMPONENTS = [ ItemPageTitleFieldComponent, ThemedSearchNavbarComponent, ListableNotificationObjectComponent, + DsoPageSubscriptionButtonComponent, MetadataFieldWrapperComponent, ContextHelpWrapperComponent, EpersonGroupListComponent, diff --git a/src/app/shared/subscriptions/models/subscription.model.ts b/src/app/shared/subscriptions/models/subscription.model.ts new file mode 100644 index 0000000000..b460a0418f --- /dev/null +++ b/src/app/shared/subscriptions/models/subscription.model.ts @@ -0,0 +1,73 @@ +import { Observable } from 'rxjs'; +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; + +import { link, typedObject } from '../../../core/cache/builders/build-decorators'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { HALLink } from '../../../core/shared/hal-link.model'; +import { SUBSCRIPTION } from './subscription.resource-type'; +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { EPERSON } from '../../../core/eperson/models/eperson.resource-type'; +import { DSPACE_OBJECT } from '../../../core/shared/dspace-object.resource-type'; + +@typedObject +@inheritSerialization(DSpaceObject) +export class Subscription extends DSpaceObject { + static type = SUBSCRIPTION; + + /** + * A string representing subscription type + */ + @autoserialize + public id: string; + + /** + * A string representing subscription type + */ + @autoserialize + public subscriptionType: string; + + /** + * An array of parameters for the subscription + */ + @autoserialize + public subscriptionParameterList: SubscriptionParameterList[]; + + /** + * The {@link HALLink}s for this Subscription + */ + @deserialize + _links: { + self: HALLink; + eperson: HALLink; + resource: HALLink; + }; + + /** + * The logo for this Community + * Will be undefined unless the logo {@link HALLink} has been resolved. + */ + @link(EPERSON) + eperson?: Observable>; + + /** + * The logo for this Community + * Will be undefined unless the logo {@link HALLink} has been resolved. + */ + @link(DSPACE_OBJECT) + resource?: Observable>; + /** + * The embedded ePerson & dSpaceObject for this Subscription + */ + /* @deserialize + _embedded: { + ePerson: EPerson; + dSpaceObject: DSpaceObject; + };*/ +} + +export interface SubscriptionParameterList { + id: string; + name: string; + value: string; +} diff --git a/src/app/shared/subscriptions/models/subscription.resource-type.ts b/src/app/shared/subscriptions/models/subscription.resource-type.ts new file mode 100644 index 0000000000..a1dad5cba0 --- /dev/null +++ b/src/app/shared/subscriptions/models/subscription.resource-type.ts @@ -0,0 +1,10 @@ +import { ResourceType } from '../../../core/shared/resource-type'; + +/** + * The resource type for Subscription + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ + +export const SUBSCRIPTION = new ResourceType('subscription'); diff --git a/src/app/shared/subscriptions/subscription-modal/subscription-modal.component.html b/src/app/shared/subscriptions/subscription-modal/subscription-modal.component.html new file mode 100644 index 0000000000..a71498f002 --- /dev/null +++ b/src/app/shared/subscriptions/subscription-modal/subscription-modal.component.html @@ -0,0 +1,46 @@ +
+ + + +
diff --git a/src/app/shared/subscriptions/subscription-modal/subscription-modal.component.scss b/src/app/shared/subscriptions/subscription-modal/subscription-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/subscriptions/subscription-modal/subscription-modal.component.spec.ts b/src/app/shared/subscriptions/subscription-modal/subscription-modal.component.spec.ts new file mode 100644 index 0000000000..90f2e51e63 --- /dev/null +++ b/src/app/shared/subscriptions/subscription-modal/subscription-modal.component.spec.ts @@ -0,0 +1,260 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { NgbActiveModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { SubscriptionModalComponent } from './subscription-modal.component'; +import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { SubscriptionsDataService } from '../subscriptions-data.service'; +import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; +import { Item } from '../../../core/shared/item.model'; +import { AuthService } from '../../../core/auth/auth.service'; +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { By } from '@angular/platform-browser'; +import { subscriptionMock, subscriptionMock2 } from '../../testing/subscriptions-data.mock'; + +describe('SubscriptionModalComponent', () => { + let component: SubscriptionModalComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + let subscriptionServiceStub; + + const notificationServiceStub = jasmine.createSpyObj('authService', { + notificationWithAnchor: true, + success: undefined, + }); + + const emptyPageInfo = Object.assign(new PageInfo(), { + 'elementsPerPage': 0, + 'totalElements': 0 + }); + + + const pageInfo = Object.assign(new PageInfo(), { + 'elementsPerPage': 2, + 'totalElements': 2 + }); + + const mockEperson = Object.assign(new EPerson(), { + id: 'fake-id', + uuid: 'fake-id', + _links: { + self: { + href: 'https://localhost:8000/eperson/fake-id' + } + } + }); + + const mockItem = Object.assign(new Item(), { + id: 'fake-id', + uuid: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + _links: { + self: { + href: 'https://localhost:8000/items/fake-id' + } + } + }); + + const authService = jasmine.createSpyObj('authService', { + getAuthenticatedUserFromStore: createSuccessfulRemoteDataObject$(mockEperson) + }); + + subscriptionServiceStub = jasmine.createSpyObj('SubscriptionsDataService', { + getSubscriptionsByPersonDSO: jasmine.createSpy('getSubscriptionsByPersonDSO'), + createSubscription: createSuccessfulRemoteDataObject$({}), + updateSubscription: createSuccessfulRemoteDataObject$({}), + }); + + beforeEach(waitForAsync(() => { + + TestBed.configureTestingModule({ + imports: [ + CommonModule, + NgbModalModule, + ReactiveFormsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + declarations: [SubscriptionModalComponent], + providers: [ + NgbActiveModal, + { provide: AuthService, useValue: authService }, + { provide: NotificationsService, useValue: notificationServiceStub }, + { provide: SubscriptionsDataService, useValue: subscriptionServiceStub }, + ], + schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + + })); + + describe('when submitting subscriptions', () => { + + const testSubscriptionId = 'test-subscription-id'; + const testTypes = ['test1', 'test2']; + const testFrequencies = ['f', 'g']; + + beforeEach(() => { + fixture = TestBed.createComponent(SubscriptionModalComponent); + component = fixture.componentInstance; + component.dso = mockItem; + (component as any).subscriptionDefaultTypes = testTypes; + (component as any).frequencyDefaultValues = testFrequencies; + de = fixture.debugElement; + subscriptionServiceStub.createSubscription.calls.reset(); + subscriptionServiceStub.updateSubscription.calls.reset(); + fixture.detectChanges(); + }); + + it('should edit an existing subscription', () => { + component.subscriptionForm = new FormGroup({}); + for (let t of testTypes) { + const formGroup = new FormGroup({ + subscriptionId: new FormControl(testSubscriptionId), + frequencies: new FormGroup({ + f: new FormControl(false), + g: new FormControl(true), + }) + }); + component.subscriptionForm.addControl(t, formGroup); + component.subscriptionForm.get('test1').markAsDirty(); + component.subscriptionForm.get('test1').markAsTouched(); + } + + fixture.detectChanges(); + component.submit(); + + expect(subscriptionServiceStub.createSubscription).not.toHaveBeenCalled(); + expect(subscriptionServiceStub.updateSubscription).toHaveBeenCalled(); + expect(component.subscriptionForm.controls).toBeTruthy(); + }); + + it('should create a new subscription', () => { + component.subscriptionForm = new FormGroup({}); + for (let t of testTypes) { + const formGroup = new FormGroup({ + subscriptionId: new FormControl(undefined), + frequencies: new FormGroup({ + f: new FormControl(false), + g: new FormControl(true), + }) + }); + component.subscriptionForm.addControl(t, formGroup); + component.subscriptionForm.get('test1').markAsDirty(); + component.subscriptionForm.get('test1').markAsTouched(); + } + + fixture.detectChanges(); + component.submit(); + + expect(subscriptionServiceStub.createSubscription).toHaveBeenCalled(); + expect(subscriptionServiceStub.updateSubscription).not.toHaveBeenCalled(); + expect(component.subscriptionForm.controls).toBeTruthy(); + }); + + }); + + describe('when no subscription is given', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubscriptionModalComponent); + component = fixture.componentInstance; + component.dso = mockItem; + (component as any).subscriptionDefaultTypes = ['test1', 'test2']; + de = fixture.debugElement; + }); + + describe('and no subscriptions are present for the given dso', () => { + beforeEach(() => { + subscriptionServiceStub.getSubscriptionsByPersonDSO.and.returnValue(createSuccessfulRemoteDataObject$(buildPaginatedList(emptyPageInfo, []))); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should init form properly', () => { + expect(de.query(By.css(' [data-test="subscription-form"]'))).toBeTruthy(); + expect(component.subscriptionForm).toBeTruthy(); + expect(component.subscriptionForm.get('test1')).toBeTruthy(); + expect(component.subscriptionForm.get('test2')).toBeTruthy(); + (component as any).frequencyDefaultValues.forEach((frequency) => { + expect(component.subscriptionForm.get('test1').get('frequencies').get(frequency)).toBeTruthy(); + expect(component.subscriptionForm.get('test2').get('frequencies').get(frequency)).toBeTruthy(); + }); + }); + }); + + describe('and subscriptions are present for the given dso', () => { + beforeEach(() => { + subscriptionServiceStub.getSubscriptionsByPersonDSO.and.returnValue(createSuccessfulRemoteDataObject$(buildPaginatedList(pageInfo, [subscriptionMock, subscriptionMock2]))); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should init form properly', () => { + expect(de.query(By.css(' [data-test="subscription-form"]'))).toBeTruthy(); + expect(component.subscriptionForm).toBeTruthy(); + expect(component.subscriptionForm.get('test1')).toBeTruthy(); + expect(component.subscriptionForm.get('test2')).toBeTruthy(); + (component as any).frequencyDefaultValues.forEach((frequency) => { + expect(component.subscriptionForm.get('test1').get('frequencies').get(frequency)).toBeTruthy(); + + expect(component.subscriptionForm.get('test2').get('frequencies').get(frequency)).toBeTruthy(); + }); + expect(component.subscriptionForm.get('test1').get('frequencies').get('D').value).toBeTrue(); + expect(component.subscriptionForm.get('test1').get('frequencies').get('M').value).toBeTrue(); + expect(component.subscriptionForm.get('test1').get('frequencies').get('W').value).toBeFalse(); + + expect(component.subscriptionForm.get('test2').get('frequencies').get('D').value).toBeTrue(); + expect(component.subscriptionForm.get('test2').get('frequencies').get('M').value).toBeFalse(); + expect(component.subscriptionForm.get('test2').get('frequencies').get('W').value).toBeFalse(); + }); + }); + }); + + describe('when no subscription is given', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubscriptionModalComponent); + component = fixture.componentInstance; + component.dso = mockItem; + component.subscription = subscriptionMock as any; + (component as any).subscriptionDefaultTypes = ['test1', 'test2']; + de = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should init form properly', () => { + expect(de.query(By.css(' [data-test="subscription-form"]'))).toBeTruthy(); + expect(component.subscriptionForm).toBeTruthy(); + expect(component.subscriptionForm.get('test1')).toBeTruthy(); + (component as any).frequencyDefaultValues.forEach((frequency) => { + expect(component.subscriptionForm.get('test1').get('frequencies').get(frequency)).toBeTruthy(); + }); + expect(component.subscriptionForm.get('test1').get('frequencies').get('D').value).toBeTrue(); + expect(component.subscriptionForm.get('test1').get('frequencies').get('M').value).toBeTrue(); + expect(component.subscriptionForm.get('test1').get('frequencies').get('W').value).toBeFalse(); + }); + }); + +}); diff --git a/src/app/shared/subscriptions/subscription-modal/subscription-modal.component.ts b/src/app/shared/subscriptions/subscription-modal/subscription-modal.component.ts new file mode 100644 index 0000000000..cd37a2bb02 --- /dev/null +++ b/src/app/shared/subscriptions/subscription-modal/subscription-modal.component.ts @@ -0,0 +1,281 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; + +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +import { BehaviorSubject, combineLatest, from, shareReplay } from 'rxjs'; +import { map, mergeMap, take, tap } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; +import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import findIndex from 'lodash/findIndex'; + +import { Subscription } from '../models/subscription.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { SubscriptionsDataService } from '../subscriptions-data.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; +import { AuthService } from '../../../core/auth/auth.service'; +import { isNotEmpty } from '../../empty.util'; + +@Component({ + selector: 'ds-subscription-modal', + templateUrl: './subscription-modal.component.html', + styleUrls: ['./subscription-modal.component.scss'] +}) +/** + * Modal that allows to manage the subscriptions for the selected item + */ +export class SubscriptionModalComponent implements OnInit { + + /** + * DSpaceObject of which to get the subscriptions + */ + @Input() dso: DSpaceObject; + + /** + * If given the subscription to edit by the form + */ + @Input() subscription: Subscription; + + /** + * The eperson related to the subscription + */ + ePersonId: string; + + /** + * A boolean representing if a request operation is pending + * @type {BehaviorSubject} + */ + public processing$ = new BehaviorSubject(false); + + /** + * If true, show a message explaining how to delete a subscription + */ + public showDeleteInfo$ = new BehaviorSubject(false); + + /** + * Reactive form group that will be used to add/edit subscriptions + */ + subscriptionForm: FormGroup; + + /** + * Used to show validation errors when user submits + */ + submitted = false; + + /** + * Types of subscription to be shown on select + */ + subscriptionDefaultTypes = ['content']; + + /** + * Frequencies to be shown as checkboxes + */ + frequencyDefaultValues = ['D', 'W', 'M']; + + /** + * True if form status has changed and at least one frequency is checked + */ + isValid = false; + /** + * Event emitted when a given subscription has been updated + */ + @Output() updateSubscription: EventEmitter = new EventEmitter(); + + constructor( + private formBuilder: FormBuilder, + private modalService: NgbModal, + private notificationsService: NotificationsService, + private subscriptionService: SubscriptionsDataService, + public activeModal: NgbActiveModal, + private authService: AuthService, + private translate: TranslateService, + ) { + } + + /** + * When component starts initialize starting functionality + */ + ngOnInit(): void { + this.authService.getAuthenticatedUserFromStore().pipe( + take(1), + map((ePerson) => ePerson.uuid), + shareReplay(), + ).subscribe((ePersonId: string) => { + this.ePersonId = ePersonId; + if (isNotEmpty(this.subscription)) { + this.initFormByGivenSubscription(); + } else { + this.initFormByAllSubscriptions(); + } + }); + + this.subscriptionForm.valueChanges.subscribe((newValue) => { + let anyFrequencySelected = false; + for (let f of this.frequencyDefaultValues) { + anyFrequencySelected = anyFrequencySelected || newValue.content.frequencies[f]; + } + this.isValid = anyFrequencySelected; + }); + } + + initFormByAllSubscriptions(): void { + this.subscriptionForm = new FormGroup({}); + for (let t of this.subscriptionDefaultTypes) { + const formGroup = new FormGroup({}); + formGroup.addControl('subscriptionId', this.formBuilder.control('')); + formGroup.addControl('frequencies', this.formBuilder.group({})); + for (let f of this.frequencyDefaultValues) { + (formGroup.controls.frequencies as FormGroup).addControl(f, this.formBuilder.control(false)); + } + this.subscriptionForm.addControl(t, formGroup); + } + + this.initFormDataBySubscriptions(); + } + + /** + * If the subscription is passed start the form with the information of subscription + */ + initFormByGivenSubscription(): void { + const formGroup = new FormGroup({}); + formGroup.addControl('subscriptionId', this.formBuilder.control(this.subscription.id)); + formGroup.addControl('frequencies', this.formBuilder.group({})); + (formGroup.get('frequencies') as FormGroup).addValidators(Validators.required); + for (let f of this.frequencyDefaultValues) { + const value = findIndex(this.subscription.subscriptionParameterList, ['value', f]) !== -1; + (formGroup.controls.frequencies as FormGroup).addControl(f, this.formBuilder.control(value)); + } + + this.subscriptionForm = this.formBuilder.group({ + [this.subscription.subscriptionType]: formGroup + }); + } + + /** + * Get subscriptions for the current ePerson & dso object relation. + * If there are no subscriptions then start with an empty form. + */ + initFormDataBySubscriptions(): void { + this.processing$.next(true); + this.subscriptionService.getSubscriptionsByPersonDSO(this.ePersonId, this.dso?.uuid).pipe( + getFirstSucceededRemoteDataPayload(), + ).subscribe({ + next: (res: PaginatedList) => { + if (res.pageInfo.totalElements > 0) { + this.showDeleteInfo$.next(true); + for (let subscription of res.page) { + const type = subscription.subscriptionType; + const subscriptionGroup: FormGroup = this.subscriptionForm.get(type) as FormGroup; + if (isNotEmpty(subscriptionGroup)) { + subscriptionGroup.controls.subscriptionId.setValue(subscription.id); + for (let parameter of subscription.subscriptionParameterList.filter((p) => p.name === 'frequency')) { + (subscriptionGroup.controls.frequencies as FormGroup).controls[parameter.value]?.setValue(true); + } + } + } + } + this.processing$.next(false); + }, + error: err => { + this.processing$.next(false); + } + }); + } + + /** + * Create/update subscriptions if needed + */ + submit() { + this.submitted = true; + const subscriptionTypes: string[] = Object.keys(this.subscriptionForm.controls); + const subscriptionsToBeCreated = []; + const subscriptionsToBeUpdated = []; + + subscriptionTypes.forEach((subscriptionType: string) => { + const subscriptionGroup: FormGroup = this.subscriptionForm.controls[subscriptionType] as FormGroup; + if (subscriptionGroup.touched && subscriptionGroup.dirty) { + const body = this.createBody( + subscriptionGroup.controls.subscriptionId.value, + subscriptionType, + subscriptionGroup.controls.frequencies as FormGroup + ); + + if (isNotEmpty(body.id)) { + subscriptionsToBeUpdated.push(body); + } else if (isNotEmpty(body.subscriptionParameterList)) { + subscriptionsToBeCreated.push(body); + } + } + + }); + + const toBeProcessed = []; + if (isNotEmpty(subscriptionsToBeCreated)) { + toBeProcessed.push(from(subscriptionsToBeCreated).pipe( + mergeMap((subscriptionBody) => { + return this.subscriptionService.createSubscription(subscriptionBody, this.ePersonId, this.dso.uuid).pipe( + getFirstCompletedRemoteData() + ); + }), + tap((res: RemoteData) => { + if (res.hasSucceeded) { + const msg = this.translate.instant('subscriptions.modal.create.success', { type: res.payload.subscriptionType }); + this.notificationsService.success(null, msg); + } else { + this.notificationsService.error(null, this.translate.instant('subscriptions.modal.create.error')); + } + }) + )); + } + + if (isNotEmpty(subscriptionsToBeUpdated)) { + toBeProcessed.push(from(subscriptionsToBeUpdated).pipe( + mergeMap((subscriptionBody) => { + return this.subscriptionService.updateSubscription(subscriptionBody, this.ePersonId, this.dso.uuid).pipe( + getFirstCompletedRemoteData() + ); + }), + tap((res: RemoteData) => { + if (res.hasSucceeded) { + const msg = this.translate.instant('subscriptions.modal.update.success', { type: res.payload.subscriptionType }); + this.notificationsService.success(null, msg); + if (isNotEmpty(this.subscription)) { + this.updateSubscription.emit(res.payload); + } + } else { + this.notificationsService.error(null, this.translate.instant('subscriptions.modal.update.error')); + } + }) + )); + } + + combineLatest([...toBeProcessed]).subscribe((res) => { + this.activeModal.close(); + }); + + } + + private createBody(subscriptionId: string, subscriptionType: string, frequencies: FormGroup): Partial { + const body = { + id: (isNotEmpty(subscriptionId) ? subscriptionId : null), + subscriptionType: subscriptionType, + subscriptionParameterList: [] + }; + + for (let frequency of this.frequencyDefaultValues) { + if (frequencies.value[frequency]) { + body.subscriptionParameterList.push( + { + name: 'frequency', + value: frequency, + } + ); + } + } + + return body; + } + +} diff --git a/src/app/shared/subscriptions/subscription-view/subscription-view.component.html b/src/app/shared/subscriptions/subscription-view/subscription-view.component.html new file mode 100644 index 0000000000..1b3b0b54b7 --- /dev/null +++ b/src/app/shared/subscriptions/subscription-view/subscription-view.component.html @@ -0,0 +1,35 @@ + + + +

{{dso.name}}

+
+ +

{{ 'subscriptions.table.not-available' | translate }}

+

{{ 'subscriptions.table.not-available-message' | translate }}

+
+ + + {{subscription.subscriptionType}} + + + + + {{ 'subscriptions.frequency.' + parameterList.value | translate}}, + + + + +
+ + +
+ diff --git a/src/app/shared/subscriptions/subscription-view/subscription-view.component.scss b/src/app/shared/subscriptions/subscription-view/subscription-view.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/subscriptions/subscription-view/subscription-view.component.spec.ts b/src/app/shared/subscriptions/subscription-view/subscription-view.component.spec.ts new file mode 100644 index 0000000000..aa40f2bf43 --- /dev/null +++ b/src/app/shared/subscriptions/subscription-view/subscription-view.component.spec.ts @@ -0,0 +1,133 @@ +import { ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing'; + +// Import modules +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; +import { BrowserModule, By } from '@angular/platform-browser'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { SharedModule } from '../../shared.module'; +import { DebugElement } from '@angular/core'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { SubscriptionViewComponent } from './subscription-view.component'; + +// Import mocks +import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; +import { findByEPersonAndDsoResEmpty, subscriptionMock } from '../../testing/subscriptions-data.mock'; + +// Import utils +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; +import { SubscriptionsDataService } from '../subscriptions-data.service'; +import { Subscription } from '../models/subscription.model'; + +import { of as observableOf } from 'rxjs'; + +import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; +import { Item } from '../../../core/shared/item.model'; +import { ITEM } from '../../../core/shared/item.resource-type'; + +describe('SubscriptionViewComponent', () => { + let component: SubscriptionViewComponent; + let fixture: ComponentFixture; + let de: DebugElement; + let modalService; + + const subscriptionServiceStub = jasmine.createSpyObj('SubscriptionsDataService', { + getSubscriptionByPersonDSO: observableOf(findByEPersonAndDsoResEmpty), + deleteSubscription: createSuccessfulRemoteDataObject$({}), + updateSubscription: createSuccessfulRemoteDataObject$({}), + }); + + const mockItem = Object.assign(new Item(), { + id: 'fake-id', + uuid: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + type: ITEM, + _links: { + self: { + href: 'https://localhost:8000/items/fake-id' + } + } + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + NgbModule, + ReactiveFormsModule, + BrowserModule, + RouterTestingModule, + SharedModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + declarations: [ SubscriptionViewComponent ], + providers: [ + { provide: ComponentFixtureAutoDetect, useValue: true }, + { provide: NotificationsService, useValue: NotificationsServiceStub }, + { provide: SubscriptionsDataService, useValue: subscriptionServiceStub }, + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SubscriptionViewComponent); + component = fixture.componentInstance; + component.eperson = 'testid123'; + component.dso = mockItem; + component.subscription = Object.assign(new Subscription(), subscriptionMock); + de = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have dso object info', () => { + expect(de.query(By.css('.dso-info > ds-type-badge'))).toBeTruthy(); + expect(de.query(By.css('.dso-info > p > a'))).toBeTruthy(); + }); + + it('should have subscription type info', () => { + expect(de.query(By.css('.subscription-type'))).toBeTruthy(); + }); + + it('should have subscription paramenter info', () => { + expect(de.query(By.css('.subscription-parameters > span'))).toBeTruthy(); + }); + + it('should have subscription action info', () => { + expect(de.query(By.css('.btn-outline-primary'))).toBeTruthy(); + expect(de.query(By.css('.btn-outline-danger'))).toBeTruthy(); + }); + + it('should open modal when clicked edit button', () => { + modalService = (component as any).modalService; + const modalSpy = spyOn(modalService, 'open'); + + const editBtn = de.query(By.css('.btn-outline-primary')).nativeElement; + editBtn.click(); + + expect(modalService.open).toHaveBeenCalled(); + }); + + it('should call delete function when clicked delete button', () => { + const deleteSpy = spyOn(component, 'deleteSubscriptionPopup'); + + const deleteBtn = de.query(By.css('.btn-outline-danger')).nativeElement; + deleteBtn.click(); + + expect(deleteSpy).toHaveBeenCalled(); + }); + +}); diff --git a/src/app/shared/subscriptions/subscription-view/subscription-view.component.ts b/src/app/shared/subscriptions/subscription-view/subscription-view.component.ts new file mode 100644 index 0000000000..4c6c5c6d21 --- /dev/null +++ b/src/app/shared/subscriptions/subscription-view/subscription-view.component.ts @@ -0,0 +1,109 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Subscription } from '../models/subscription.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; + +import { take } from 'rxjs/operators'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; + +import { hasValue } from '../../empty.util'; +import { ConfirmationModalComponent } from '../../confirmation-modal/confirmation-modal.component'; +import { SubscriptionsDataService } from '../subscriptions-data.service'; +import { getCommunityModuleRoute } from '../../../community-page/community-page-routing-paths'; +import { getCollectionModuleRoute } from '../../../collection-page/collection-page-routing-paths'; +import { getItemModuleRoute } from '../../../item-page/item-page-routing-paths'; +import { SubscriptionModalComponent } from '../subscription-modal/subscription-modal.component'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: '[ds-subscription-view]', + templateUrl: './subscription-view.component.html', + styleUrls: ['./subscription-view.component.scss'] +}) +/** + * Table row representing a subscription that displays all information and action buttons to manage it + */ +export class SubscriptionViewComponent { + + /** + * Subscription to be rendered + */ + @Input() subscription: Subscription; + + /** + * DSpaceObject of the subscription + */ + @Input() dso: DSpaceObject; + + /** + * EPerson of the subscription + */ + @Input() eperson: string; + + /** + * When an action is made emit a reload event + */ + @Output() reload = new EventEmitter(); + + /** + * Reference to NgbModal + */ + public modalRef: NgbModalRef; + + constructor( + private modalService: NgbModal, + private subscriptionService: SubscriptionsDataService, + ) { } + + /** + * Return the prefix of the route to the dso object page ( e.g. "items") + */ + getPageRoutePrefix(): string { + let routePrefix; + switch (this.dso.type.toString()) { + case 'community': + routePrefix = getCommunityModuleRoute(); + break; + case 'collection': + routePrefix = getCollectionModuleRoute(); + break; + case 'item': + routePrefix = getItemModuleRoute(); + break; + } + return routePrefix; + } + + /** + * Deletes Subscription, show notification on success/failure & updates list + * @param subscription Subscription to be deleted + */ + deleteSubscriptionPopup(subscription: Subscription) { + if (hasValue(subscription.id)) { + const modalRef = this.modalService.open(ConfirmationModalComponent); + modalRef.componentInstance.dso = this.dso; + modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-subscription.header'; + modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-subscription.info'; + modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-subscription.cancel'; + modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-subscription.confirm'; + modalRef.componentInstance.brandColor = 'danger'; + modalRef.componentInstance.confirmIcon = 'fas fa-trash'; + modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => { + if (confirm) { + this.subscriptionService.deleteSubscription(subscription.id).subscribe( (res) => { + this.reload.emit(); + }); + } + }); + } + } + + public openSubscriptionModal() { + this.modalRef = this.modalService.open(SubscriptionModalComponent); + this.modalRef.componentInstance.dso = this.dso; + this.modalRef.componentInstance.subscription = this.subscription; + this.modalRef.componentInstance.updateSubscription.pipe(take(1)).subscribe((subscription: Subscription) => { + this.subscription = subscription; + }); + + } +} diff --git a/src/app/shared/subscriptions/subscriptions-data.service.spec.ts b/src/app/shared/subscriptions/subscriptions-data.service.spec.ts new file mode 100644 index 0000000000..9c4c69123d --- /dev/null +++ b/src/app/shared/subscriptions/subscriptions-data.service.spec.ts @@ -0,0 +1,133 @@ +import { SubscriptionsDataService } from './subscriptions-data.service'; +import { createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { Subscription } from './models/subscription.model'; +import { DSOChangeAnalyzer } from '../../core/data/dso-change-analyzer.service'; +import { HttpClient } from '@angular/common/http'; +import { NotificationsService } from '../notifications/notifications.service'; +import { RequestService } from '../../core/data/request.service'; +import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; +import { Store } from '@ngrx/store'; +import { ObjectCacheService } from '../../core/cache/object-cache.service'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { getMockRequestService } from '../mocks/request.service.mock'; +import { getMockRemoteDataBuildService } from '../mocks/remote-data-build.service.mock'; +import { SearchDataImpl } from '../../core/data/base/search-data'; +import { NotificationsServiceStub } from '../testing/notifications-service.stub'; +import { HALEndpointServiceStub } from '../testing/hal-endpoint-service.stub'; +import { createPaginatedList } from '../testing/utils.test'; + + +describe('SubscriptionsDataService', () => { + + + let service: SubscriptionsDataService; + let searchData: SearchDataImpl; + + let comparator: DSOChangeAnalyzer; + let http: HttpClient; + let notificationsService: NotificationsService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let store: Store; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let nameService: DSONameService; + + function initService() { + comparator = {} as any; + http = {} as HttpClient; + notificationsService = new NotificationsServiceStub() as any; + requestService = getMockRequestService(); + rdbService = getMockRemoteDataBuildService(); + halService = new HALEndpointServiceStub('linkPath') as any; + service = new SubscriptionsDataService(comparator, http, notificationsService, requestService, rdbService, store, objectCache, halService, nameService); + spyOn((service as any).deleteData, 'delete').and.returnValue(createNoContentRemoteDataObject$()); + } + + describe('createSubscription', () => { + + beforeEach(() => { + initService(); + }); + + it('should create the subscription', () => { + const id = 'test-id'; + const ePerson = 'test-ePerson'; + const subscription = new Subscription(); + service.createSubscription(subscription, ePerson, id).subscribe((res) => { + expect(requestService.generateRequestId).toHaveBeenCalled(); + expect(res.hasCompleted).toBeTrue(); + }); + }); + + }); + + describe('deleteSubscription', () => { + + beforeEach(() => { + initService(); + }); + + it('should delete the subscription', () => { + const id = 'test-id'; + service.deleteSubscription(id).subscribe((res) => { + expect((service as any).deleteData.delete).toHaveBeenCalledWith(id); + expect(res.hasCompleted).toBeTrue(); + }); + }); + + }); + + describe('updateSubscription', () => { + + beforeEach(() => { + initService(); + }); + + it('should update the subscription', () => { + const id = 'test-id'; + const ePerson = 'test-ePerson'; + const subscription = new Subscription(); + service.updateSubscription(subscription, ePerson, id).subscribe((res) => { + expect(requestService.generateRequestId).toHaveBeenCalled(); + expect(res.hasCompleted).toBeTrue(); + }); + }); + + }); + + describe('findByEPerson', () => { + + beforeEach(() => { + initService(); + }); + + it('should update the subscription', () => { + const ePersonId = 'test-ePersonId'; + spyOn(service, 'findListByHref').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList())); + service.findByEPerson(ePersonId).subscribe((res) => { + expect(service.findListByHref).toHaveBeenCalled(); + expect(res.hasCompleted).toBeTrue(); + }); + }); + + }); + + describe('getSubscriptionsByPersonDSO', () => { + + beforeEach(() => { + initService(); + }); + + it('should get the subscriptions', () => { + const id = 'test-id'; + const ePersonId = 'test-ePersonId'; + service.getSubscriptionsByPersonDSO(ePersonId, id).subscribe(() => { + expect(searchData.searchBy).toHaveBeenCalled(); + }); + }); + + }); + +}); diff --git a/src/app/shared/subscriptions/subscriptions-data.service.ts b/src/app/shared/subscriptions/subscriptions-data.service.ts new file mode 100644 index 0000000000..44e306b529 --- /dev/null +++ b/src/app/shared/subscriptions/subscriptions-data.service.ts @@ -0,0 +1,173 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, filter, map, switchMap, take } from 'rxjs/operators'; + + +import { NotificationsService } from '../notifications/notifications.service'; +import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; +import { RequestParam } from '../../core/cache/models/request-param.model'; +import { ObjectCacheService } from '../../core/cache/object-cache.service'; +import { DSOChangeAnalyzer } from '../../core/data/dso-change-analyzer.service'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { CreateRequest, PutRequest } from '../../core/data/request.models'; +import { FindListOptions } from '../../core/data/find-list-options.model'; +import { RestRequest } from '../../core/data/rest-request.model'; + +import { RequestService } from '../../core/data/request.service'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { Subscription } from './models/subscription.model'; +import { dataService } from '../../core/data/base/data-service.decorator'; +import { SUBSCRIPTION } from './models/subscription.resource-type'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { NoContent } from '../../core/shared/NoContent.model'; +import { isNotEmpty, isNotEmptyOperator } from '../empty.util'; + +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { sendRequest } from 'src/app/core/shared/request.operators'; +import { IdentifiableDataService } from '../../core/data/base/identifiable-data.service'; +import { DeleteDataImpl } from '../../core/data/base/delete-data'; +import { SearchDataImpl } from '../../core/data/base/search-data'; +import { FindAllData } from '../../core/data/base/find-all-data'; +import { followLink } from '../utils/follow-link-config.model'; + +/** + * Provides methods to retrieve subscription resources from the REST API related CRUD actions. + */ +@Injectable({ + providedIn: 'root' +}) +@dataService(SUBSCRIPTION) +export class SubscriptionsDataService extends IdentifiableDataService { + protected findByEpersonLinkPath = 'findByEPerson'; + + private deleteData: DeleteDataImpl; + private findAllData: FindAllData; + private searchData: SearchDataImpl; + + constructor( + protected comparator: DSOChangeAnalyzer, + protected http: HttpClient, + protected notificationsService: NotificationsService, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected nameService: DSONameService, + ) { + super('subscriptions', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + } + /** + * Get subscriptions for a given item or community or collection & eperson. + * + * @param eperson The eperson to search for + * @param uuid The uuid of the dsobjcet to search for + */ + getSubscriptionsByPersonDSO(eperson: string, uuid: string): Observable>> { + + const optionsWithObject = Object.assign(new FindListOptions(), { + searchParams: [ + new RequestParam('resource', uuid), + new RequestParam('eperson_id', eperson) + ] + }); + + return this.searchData.searchBy('findByEPersonAndDso', optionsWithObject, false, true); + } + + /** + * Create a subscription for a given item or community or collection. + * + * @param subscription The subscription to create + * @param ePerson The ePerson to create for + * @param uuid The uuid of the dsobjcet to create for + */ + createSubscription(subscription: Subscription, ePerson: string, uuid: string): Observable> { + + return this.halService.getEndpoint(this.linkPath).pipe( + isNotEmptyOperator(), + take(1), + map((endpointUrl: string) => `${endpointUrl}?resource=${uuid}&eperson_id=${ePerson}`), + map((endpointURL: string) => new CreateRequest(this.requestService.generateRequestId(), endpointURL, JSON.stringify(subscription))), + sendRequest(this.requestService), + switchMap((restRequest: RestRequest) => this.rdbService.buildFromRequestUUID(restRequest.uuid)), + getFirstCompletedRemoteData(), + ) as Observable>; + } + + /** + * Update a subscription for a given item or community or collection. + * + * @param subscription The subscription to update + * @param ePerson The ePerson to update for + * @param uuid The uuid of the dsobjcet to update for + */ + updateSubscription(subscription, ePerson: string, uuid: string) { + + return this.halService.getEndpoint(this.linkPath).pipe( + isNotEmptyOperator(), + take(1), + map((endpointUrl: string) => `${endpointUrl}/${subscription.id}?resource=${uuid}&eperson_id=${ePerson}`), + map((endpointURL: string) => new PutRequest(this.requestService.generateRequestId(), endpointURL, JSON.stringify(subscription))), + sendRequest(this.requestService), + switchMap((restRequest: RestRequest) => this.rdbService.buildFromRequestUUID(restRequest.uuid)), + getFirstCompletedRemoteData(), + ) as Observable>; + } + + + /** + * Deletes the subscription with a give id + * + * @param id the id of Subscription to delete + */ + deleteSubscription(id: string): Observable> { + return this.halService.getEndpoint(this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + switchMap((endpointUrl) => this.deleteData.delete(id)), + getFirstCompletedRemoteData(), + ); + } + + /** + * Retrieves the list of subscription with {@link dSpaceObject} and {@link ePerson} + * + * @param options options for the find all request + */ + findAllSubscriptions(options?): Observable>> { + return this.findAllData.findAll(options, true, true, followLink('resource'), followLink('eperson')); + } + + + /** + * Retrieves the list of subscription with {@link dSpaceObject} and {@link ePerson} + * + * @param ePersonId The eperson id + * @param options The options for the find all request + */ + findByEPerson(ePersonId: string, options?: FindListOptions): Observable>> { + const optionsWithObject = Object.assign(new FindListOptions(), options, { + searchParams: [ + new RequestParam('uuid', ePersonId) + ] + }); + + // return this.searchData.searchBy(this.findByEpersonLinkPath, optionsWithObject, true, true, followLink('dSpaceObject'), followLink('ePerson')); + + return this.getEndpoint().pipe( + map(href => `${href}/search/${this.findByEpersonLinkPath}`), + switchMap(href => this.findListByHref(href, optionsWithObject, false, true, followLink('resource'), followLink('eperson'))) + ); + + + } + +} diff --git a/src/app/shared/subscriptions/subscriptions.module.ts b/src/app/shared/subscriptions/subscriptions.module.ts new file mode 100644 index 0000000000..122bf5ca8d --- /dev/null +++ b/src/app/shared/subscriptions/subscriptions.module.ts @@ -0,0 +1,34 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { SubscriptionViewComponent } from './subscription-view/subscription-view.component'; +import { SubscriptionModalComponent } from './subscription-modal/subscription-modal.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterModule } from '@angular/router'; +import { SharedModule } from '../shared.module'; +import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; + +const COMPONENTS = [ + SubscriptionViewComponent, + SubscriptionModalComponent +]; + +@NgModule({ + declarations: [ + ...COMPONENTS + ], + imports: [ + CommonModule, + NgbModalModule, + ReactiveFormsModule, + TranslateModule, + RouterModule, + SharedModule + ], + exports: [ + ...COMPONENTS + ] +}) +export class SubscriptionsModule { +} diff --git a/src/app/shared/testing/router.stub.ts b/src/app/shared/testing/router.stub.ts index 8630e16b2e..31bee0b6fe 100644 --- a/src/app/shared/testing/router.stub.ts +++ b/src/app/shared/testing/router.stub.ts @@ -9,4 +9,10 @@ export class RouterStub { navigateByUrl(url): void { this.url = url; } + createUrlTree(commands, navigationExtras = {}) { + return '/testing-url'; + } + serializeUrl(commands, navExtras = {}) { + return '/testing-url'; + } } diff --git a/src/app/shared/testing/subscriptions-data.mock.ts b/src/app/shared/testing/subscriptions-data.mock.ts new file mode 100644 index 0000000000..07108ad516 --- /dev/null +++ b/src/app/shared/testing/subscriptions-data.mock.ts @@ -0,0 +1,160 @@ +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { Item } from '../../core/shared/item.model'; +import { ITEM_TYPE } from '../../core/shared/item-relationships/item-type.resource-type'; + +export const mockSubscriptionEperson = Object.assign(new EPerson(), { + 'id': 'fake-eperson-id', + 'uuid': 'fake-eperson-id', + 'handle': null, + 'metadata': { + 'eperson.firstname': [ + { + 'value': 'user', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + } + ], + 'eperson.lastname': [ + { + 'value': 'testr', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + } + ] + }, + 'netid': null, + 'lastActive': '2021-09-01T12:06:19.000+00:00', + 'canLogIn': true, + 'email': 'user@test.com', + 'requireCertificate': false, + 'selfRegistered': false, + 'type': 'eperson', + '_links': { + 'groups': { + 'href': 'https://dspace.org/server/api/eperson/epersons/fake-eperson-id/groups' + }, + 'self': { + 'href': 'https://dspace.org/server/api/eperson/epersons/fake-eperson-id' + } + } +}); + +export const mockSubscriptionDSO = Object.assign(new Item(), + { + id: 'fake-item-id', + uuid: 'fake-item-id', + metadata: { + 'dc.title': [{ value: 'test item subscription' }] + }, + type: ITEM_TYPE, + _links: { + self: { + href: 'https://dspace.org/server/api/core/items/fake-item-id' + } + } + } +); + +export const mockSubscriptionDSO2 = Object.assign(new Item(), + { + id: 'fake-item-id2', + uuid: 'fake-item-id2', + metadata: { + 'dc.title': [{ value: 'test item subscription 2' }] + }, + type: ITEM_TYPE, + _links: { + self: { + href: 'https://dspace.org/server/api/core/items/fake-item-id2' + } + } + } +); +export const findByEPersonAndDsoResEmpty = { + 'type': { + 'value': 'paginated-list' + }, + 'pageInfo': { + 'elementsPerPage': 0, + 'totalElements': 0, + 'totalPages': 1, + 'currentPage': 1 + }, + '_links': { + 'self': { + 'href': 'https://dspacecris7.4science.cloud/server/api/core/subscriptions/search/findByEPersonAndDso?resource=092b59e8-8159-4e70-98b5-93ec60bd3431&eperson_id=335647b6-8a52-4ecb-a8c1-7ebabb199bda' + }, + 'page': [ + { + 'href': 'https://dspacecris7.4science.cloud/server/api/core/subscriptions/22' + }, + { + 'href': 'https://dspacecris7.4science.cloud/server/api/core/subscriptions/48' + } + ] + }, + 'page': [] +}; + +export const subscriptionMock = { + 'id': 21, + 'type': 'subscription', + 'subscriptionParameterList': [ + { + 'id': 77, + 'name': 'frequency', + 'value': 'D' + }, + { + 'id': 78, + 'name': 'frequency', + 'value': 'M' + } + ], + 'subscriptionType': 'test1', + 'ePerson': createSuccessfulRemoteDataObject$(mockSubscriptionEperson), + 'dSpaceObject': createSuccessfulRemoteDataObject$(mockSubscriptionDSO), + '_links': { + 'dSpaceObject': { + 'href': 'https://dspace/server/api/core/subscriptions/21/dSpaceObject' + }, + 'ePerson': { + 'href': 'https://dspace/server/api/core/subscriptions/21/ePerson' + }, + 'self': { + 'href': 'https://dspace/server/api/core/subscriptions/21' + } + } +}; + +export const subscriptionMock2 = { + 'id': 21, + 'type': 'subscription', + 'subscriptionParameterList': [ + { + 'id': 77, + 'name': 'frequency', + 'value': 'D' + }, + ], + 'subscriptionType': 'test2', + 'ePerson': createSuccessfulRemoteDataObject$(mockSubscriptionEperson), + 'dSpaceObject': createSuccessfulRemoteDataObject$(mockSubscriptionDSO2), + '_links': { + 'dSpaceObject': { + 'href': 'https://dspacecris7.4science.cloud/server/api/core/subscriptions/21/dSpaceObject' + }, + 'ePerson': { + 'href': 'https://dspacecris7.4science.cloud/server/api/core/subscriptions/21/ePerson' + }, + 'self': { + 'href': 'https://dspacecris7.4science.cloud/server/api/core/subscriptions/21' + } + } +}; + diff --git a/src/app/shared/testing/test-module.ts b/src/app/shared/testing/test-module.ts index 7d45dd41f3..85fa295dc2 100644 --- a/src/app/shared/testing/test-module.ts +++ b/src/app/shared/testing/test-module.ts @@ -25,9 +25,10 @@ import { BrowserOnlyMockPipe } from './browser-only-mock.pipe'; NgComponentOutletDirectiveStub, BrowserOnlyMockPipe, ], - exports: [ - QueryParamsDirectiveStub - ], + exports: [ + QueryParamsDirectiveStub, + RouterLinkDirectiveStub + ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] diff --git a/src/app/subscriptions-page/subscriptions-page-routing.module.ts b/src/app/subscriptions-page/subscriptions-page-routing.module.ts new file mode 100644 index 0000000000..149c9a415f --- /dev/null +++ b/src/app/subscriptions-page/subscriptions-page-routing.module.ts @@ -0,0 +1,27 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { SubscriptionsPageModule } from './subscriptions-page.module'; +import { SubscriptionsPageComponent } from './subscriptions-page.component'; + + +@NgModule({ + imports: [ + SubscriptionsPageModule, + RouterModule.forChild([ + { + path: '', + data: { + title: 'subscriptions.title', + }, + children: [ + { + path: '', + component: SubscriptionsPageComponent, + }, + ] + }, + ]) + ] +}) +export class SubscriptionsPageRoutingModule { +} diff --git a/src/app/subscriptions-page/subscriptions-page.component.html b/src/app/subscriptions-page/subscriptions-page.component.html new file mode 100644 index 0000000000..ed31e16759 --- /dev/null +++ b/src/app/subscriptions-page/subscriptions-page.component.html @@ -0,0 +1,47 @@ +
+
+
+

{{'subscriptions.title' | translate}}

+
+
+ + + + +
+ + + + + + + + + + + + + +
{{'subscriptions.table.dso' | translate}}{{'subscriptions.table.subscription_type' | translate}}{{'subscriptions.table.subscription_frequency' | translate}}{{'subscriptions.table.action' | translate}}
+
+
+ + + {{ 'subscriptions.table.empty.message' | translate }} + + +
+
+ +
+
+
+
diff --git a/src/app/subscriptions-page/subscriptions-page.component.scss b/src/app/subscriptions-page/subscriptions-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/subscriptions-page/subscriptions-page.component.spec.ts b/src/app/subscriptions-page/subscriptions-page.component.spec.ts new file mode 100644 index 0000000000..4f44392428 --- /dev/null +++ b/src/app/subscriptions-page/subscriptions-page.component.spec.ts @@ -0,0 +1,127 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { BrowserModule, By } from '@angular/platform-browser'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { SubscriptionsPageComponent } from './subscriptions-page.component'; +import { PaginationService } from '../core/pagination/pagination.service'; +import { SubscriptionsDataService } from '../shared/subscriptions/subscriptions-data.service'; +import { PaginationServiceStub } from '../shared/testing/pagination-service.stub'; +import { AuthService } from '../core/auth/auth.service'; +import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; +import { + mockSubscriptionEperson, + subscriptionMock, + subscriptionMock2 +} from '../shared/testing/subscriptions-data.mock'; +import { MockActivatedRoute } from '../shared/mocks/active-router.mock'; +import { VarDirective } from '../shared/utils/var.directive'; +import { SubscriptionViewComponent } from '../shared/subscriptions/subscription-view/subscription-view.component'; +import { PageInfo } from '../core/shared/page-info.model'; +import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { buildPaginatedList } from '../core/data/paginated-list.model'; + +describe('SubscriptionsPageComponent', () => { + let component: SubscriptionsPageComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + const authServiceStub = jasmine.createSpyObj('authorizationService', { + getAuthenticatedUserFromStore: observableOf(mockSubscriptionEperson) + }); + + const subscriptionServiceStub = jasmine.createSpyObj('SubscriptionsDataService', { + findByEPerson: jasmine.createSpy('findByEPerson') + }); + + const paginationService = new PaginationServiceStub(); + + const mockSubscriptionList = [subscriptionMock, subscriptionMock2]; + + const emptyPageInfo = Object.assign(new PageInfo(), { + totalElements: 0 + }); + + const pageInfo = Object.assign(new PageInfo(), { + totalElements: 2 + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + BrowserModule, + RouterTestingModule.withRoutes([]), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + NoopAnimationsModule + ], + declarations: [SubscriptionsPageComponent, SubscriptionViewComponent, VarDirective], + providers: [ + { provide: SubscriptionsDataService, useValue: subscriptionServiceStub }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: AuthService, useValue: authServiceStub }, + { provide: PaginationService, useValue: paginationService } + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SubscriptionsPageComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + }); + + describe('when there are subscriptions', () => { + + beforeEach(() => { + subscriptionServiceStub.findByEPerson.and.returnValue(createSuccessfulRemoteDataObject$(buildPaginatedList(pageInfo, mockSubscriptionList))); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show table', () => { + expect(de.query(By.css('[data-test="subscription-table"]'))).toBeTruthy(); + expect(de.query(By.css('[data-test="empty-alert"]'))).toBeNull(); + }); + + it('should show a row for each results entry',() => { + expect(de.query(By.css('[data-test="subscription-table"]'))).toBeTruthy(); + expect(de.query(By.css('[data-test="empty-alert"]'))).toBeNull(); + expect(de.queryAll(By.css('tbody > tr')).length).toEqual(2); + }); + }); + + describe('when there are no subscriptions', () => { + + beforeEach(() => { + subscriptionServiceStub.findByEPerson.and.returnValue(createSuccessfulRemoteDataObject$(buildPaginatedList(emptyPageInfo, []))); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not show table', () => { + expect(de.query(By.css('[data-test="subscription-table"]'))).toBeNull(); + expect(de.query(By.css('[data-test="empty-alert"]'))).toBeTruthy(); + }); + }); + +}); diff --git a/src/app/subscriptions-page/subscriptions-page.component.ts b/src/app/subscriptions-page/subscriptions-page.component.ts new file mode 100644 index 0000000000..05c587ba12 --- /dev/null +++ b/src/app/subscriptions-page/subscriptions-page.component.ts @@ -0,0 +1,115 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; + +import { BehaviorSubject, combineLatestWith, Observable, shareReplay, Subscription as rxjsSubscription } from 'rxjs'; +import { map, switchMap, take, tap } from 'rxjs/operators'; + +import { Subscription } from '../shared/subscriptions/models/subscription.model'; +import { buildPaginatedList, PaginatedList } from '../core/data/paginated-list.model'; +import { SubscriptionsDataService } from '../shared/subscriptions/subscriptions-data.service'; +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; +import { PaginationService } from '../core/pagination/pagination.service'; +import { PageInfo } from '../core/shared/page-info.model'; +import { AuthService } from '../core/auth/auth.service'; +import { EPerson } from '../core/eperson/models/eperson.model'; +import { getAllCompletedRemoteData } from '../core/shared/operators'; +import { RemoteData } from '../core/data/remote-data'; +import { hasValue } from '../shared/empty.util'; + +@Component({ + selector: 'ds-subscriptions-page', + templateUrl: './subscriptions-page.component.html', + styleUrls: ['./subscriptions-page.component.scss'] +}) +/** + * List and allow to manage all the active subscription for the current user + */ +export class SubscriptionsPageComponent implements OnInit, OnDestroy { + + /** + * The subscriptions to show on this page, as an Observable list. + */ + subscriptions$: BehaviorSubject> = new BehaviorSubject(buildPaginatedList(new PageInfo(), [])); + + /** + * The current pagination configuration for the page + */ + config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'elp', + pageSize: 10, + currentPage: 1 + }); + + /** + * A boolean representing if is loading + */ + loading$: BehaviorSubject = new BehaviorSubject(false); + + /** + * The current eperson id + */ + ePersonId$: Observable; + + /** + * The rxjs subscription used to retrieve the result list + */ + sub: rxjsSubscription = null; + + constructor( + private paginationService: PaginationService, + private authService: AuthService, + private subscriptionService: SubscriptionsDataService + ) { + + } + + /** + * Retrieve the current eperson id and call method to retrieve the subscriptions + */ + ngOnInit(): void { + this.ePersonId$ = this.authService.getAuthenticatedUserFromStore().pipe( + take(1), + map((ePerson: EPerson) => ePerson.id), + shareReplay() + ); + this.retrieveSubscriptions(); + } + + /** + * Retrieve subscription list related to the current user. + * When page is changed it will request the new subscriptions for the new page config + * @private + */ + private retrieveSubscriptions(): void { + this.sub = this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( + combineLatestWith(this.ePersonId$), + tap(() => this.loading$.next(true)), + switchMap(([currentPagination, ePersonId]) => this.subscriptionService.findByEPerson(ePersonId,{ + currentPage: currentPagination.currentPage, + elementsPerPage: currentPagination.pageSize + })), + getAllCompletedRemoteData() + ).subscribe((res: RemoteData>) => { + if (res.hasSucceeded) { + this.subscriptions$.next(res.payload); + } + this.loading$.next(false); + }); + } + /** + * When a subscription is deleted refresh the subscription list + */ + refresh(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + + this.retrieveSubscriptions(); + } + + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } + +} diff --git a/src/app/subscriptions-page/subscriptions-page.module.ts b/src/app/subscriptions-page/subscriptions-page.module.ts new file mode 100644 index 0000000000..f7a4dc3344 --- /dev/null +++ b/src/app/subscriptions-page/subscriptions-page.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SubscriptionsPageComponent } from './subscriptions-page.component'; +import { SharedModule } from '../shared/shared.module'; +import { SubscriptionsModule } from '../shared/subscriptions/subscriptions.module'; + +@NgModule({ + declarations: [SubscriptionsPageComponent], + imports: [ + CommonModule, + SharedModule, + SubscriptionsModule, + ] +}) +export class SubscriptionsPageModule { } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 37554b3da7..8fcac7d040 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1564,6 +1564,13 @@ "confirmation-modal.delete-profile.confirm": "Delete", + "confirmation-modal.delete-subscription.header": "Delete Subscription", + + "confirmation-modal.delete-subscription.info": "Are you sure you want to delete subscription for \"{{ dsoName }}\"", + + "confirmation-modal.delete-subscription.cancel": "Cancel", + + "confirmation-modal.delete-subscription.confirm": "Delete", "error.bitstream": "Error fetching bitstream", @@ -3141,6 +3148,8 @@ "nav.stop-impersonating": "Stop impersonating EPerson", + "nav.subscriptions" : "Subscriptions", + "nav.toggle" : "Toggle navigation", "nav.user.description" : "User profile bar", @@ -4760,6 +4769,77 @@ "submission.workspace.generic.view-help": "Select this option to view the item's metadata.", + "subscriptions.title": "Subscriptions", + + "subscriptions.item": "Subscriptions for items", + + "subscriptions.collection": "Subscriptions for collections", + + "subscriptions.community": "Subscriptions for communities", + + "subscriptions.subscription_type": "Subscription type", + + "subscriptions.frequency": "Subscription frequency", + + "subscriptions.frequency.D": "Daily", + + "subscriptions.frequency.M": "Monthly", + + "subscriptions.frequency.W": "Weekly", + + "subscriptions.tooltip": "Subscribe", + + "subscriptions.modal.title": "Subscriptions", + + "subscriptions.modal.type-frequency": "Type and frequency", + + "subscriptions.modal.close": "Close", + + "subscriptions.modal.delete-info": "To remove this subscription, please visit the \"Subscriptions\" page under your user profile", + + "subscriptions.modal.new-subscription-form.type.content": "Content", + + "subscriptions.modal.new-subscription-form.frequency.D": "Daily", + + "subscriptions.modal.new-subscription-form.frequency.W": "Weekly", + + "subscriptions.modal.new-subscription-form.frequency.M": "Monthly", + + "subscriptions.modal.new-subscription-form.submit": "Submit", + + "subscriptions.modal.new-subscription-form.processing": "Processing...", + + "subscriptions.modal.create.success": "Subscribed to {{ type }} successfully.", + + "subscriptions.modal.delete.success": "Subscription deleted successfully", + + "subscriptions.modal.update.success": "Subscription to {{ type }} updated successfully", + + "subscriptions.modal.create.error": "An error occurs during the subscription creation", + + "subscriptions.modal.delete.error": "An error occurs during the subscription delete", + + "subscriptions.modal.update.error": "An error occurs during the subscription update", + + "subscriptions.table.dso": "Subject", + + "subscriptions.table.subscription_type": "Subscription Type", + + "subscriptions.table.subscription_frequency": "Subscription Frequency", + + "subscriptions.table.action": "Action", + + "subscriptions.table.edit": "Edit", + + "subscriptions.table.delete": "Delete", + + "subscriptions.table.not-available": "Not available", + + "subscriptions.table.not-available-message": "The subscribed item has been deleted, or you don't currently have the permission to view it", + + "subscriptions.table.empty.message": "You do not have any subscriptions at this time. To subscribe to email updates for a Community or Collection, use the subscription button on the object's page.", + + "thumbnail.default.alt": "Thumbnail Image", "thumbnail.default.placeholder": "No Thumbnail Available",