diff --git a/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts b/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts index 5af18c778f..f61a3c2f71 100644 --- a/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts +++ b/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts @@ -6,7 +6,7 @@ import { GroupsRegistryComponent } from './group-registry/groups-registry.compon import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { getAccessControlModulePath } from '../admin-routing.module'; -const GROUP_EDIT_PATH = 'groups'; +export const GROUP_EDIT_PATH = 'groups'; export function getGroupEditPath(id: string) { return new URLCombiner(getAccessControlModulePath(), GROUP_EDIT_PATH, id).toString(); diff --git a/src/app/+admin/admin-routing.module.ts b/src/app/+admin/admin-routing.module.ts index 3d910761b8..b199129c4e 100644 --- a/src/app/+admin/admin-routing.module.ts +++ b/src/app/+admin/admin-routing.module.ts @@ -8,7 +8,7 @@ import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.ser import { URLCombiner } from '../core/url-combiner/url-combiner'; const REGISTRIES_MODULE_PATH = 'registries'; -const ACCESS_CONTROL_MODULE_PATH = 'access-control'; +export const ACCESS_CONTROL_MODULE_PATH = 'access-control'; export function getRegistriesModulePath() { return new URLCombiner(getAdminModulePath(), REGISTRIES_MODULE_PATH).toString(); diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts index ac6125fb1c..acb23fe592 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -29,6 +29,9 @@ import { ItemEditBitstreamDragHandleComponent } from './item-bitstreams/item-edi import { PaginatedDragAndDropBitstreamListComponent } from './item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component'; import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component'; import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component'; +import { ItemAuthorizationsComponent } from './item-authorizations/item-authorizations.component'; +import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; +import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -67,6 +70,9 @@ import { ItemVersionHistoryComponent } from './item-version-history/item-version ItemMoveComponent, ItemEditBitstreamDragHandleComponent, VirtualMetadataComponent, + ItemAuthorizationsComponent, + ResourcePolicyEditComponent, + ResourcePolicyCreateComponent, ], providers: [ BundleDataService diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index e4b1b06730..87b4b7a592 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -14,6 +14,12 @@ import { ItemMoveComponent } from './item-move/item-move.component'; import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component'; +import { ItemAuthorizationsComponent } from './item-authorizations/item-authorizations.component'; +import { ResourcePolicyTargetResolver } from '../../shared/resource-policies/resolvers/resource-policy-target.resolver'; +import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver'; +import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; +import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; +import { I18nBreadcrumbsService } from '../../core/breadcrumbs/i18n-breadcrumbs.service'; export const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; export const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; @@ -21,6 +27,7 @@ export const ITEM_EDIT_PRIVATE_PATH = 'private'; export const ITEM_EDIT_PUBLIC_PATH = 'public'; export const ITEM_EDIT_DELETE_PATH = 'delete'; export const ITEM_EDIT_MOVE_PATH = 'move'; +export const ITEM_EDIT_AUTHORIZATIONS_PATH = 'authorizations'; /** * Routing module that handles the routing for the Edit Item page administrator functionality @@ -111,12 +118,43 @@ export const ITEM_EDIT_MOVE_PATH = 'move'; path: ITEM_EDIT_MOVE_PATH, component: ItemMoveComponent, data: { title: 'item.edit.move.title' }, + }, + { + path: ITEM_EDIT_AUTHORIZATIONS_PATH, + children: [ + { + path: 'create', + resolve: { + resourcePolicyTarget: ResourcePolicyTargetResolver + }, + component: ResourcePolicyCreateComponent, + data: { title: 'resource-policies.create.page.title' } + }, + { + path: 'edit', + resolve: { + resourcePolicy: ResourcePolicyResolver + }, + component: ResourcePolicyEditComponent, + data: { title: 'resource-policies.edit.page.title' } + }, + { + path: '', + component: ItemAuthorizationsComponent, + data: { title: 'item.edit.authorizations.title' } + } + ] } ] } ]) ], - providers: [] + providers: [ + I18nBreadcrumbResolver, + I18nBreadcrumbsService, + ResourcePolicyResolver, + ResourcePolicyTargetResolver + ] }) export class EditItemPageRoutingModule { diff --git a/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.html b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.html new file mode 100644 index 0000000000..71aa7b44de --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.html @@ -0,0 +1,13 @@ +
+ + + + + + + + +
+ diff --git a/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts new file mode 100644 index 0000000000..c687c829eb --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts @@ -0,0 +1,183 @@ +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { cold } from 'jasmine-marbles'; + +import { ItemAuthorizationsComponent } from './item-authorizations.component'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { createMockRDPaginatedObs } from '../item-bitstreams/item-bitstreams.component.spec'; +import { Item } from '../../../core/shared/item.model'; +import { LinkService } from '../../../core/cache/builders/link.service'; +import { getMockLinkService } from '../../../shared/mocks/link-service.mock'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { PageInfo } from '../../../core/shared/page-info.model'; + +describe('ItemAuthorizationsComponent test suite', () => { + let comp: ItemAuthorizationsComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let de; + + const linkService: any = getMockLinkService(); + + const bitstream1 = Object.assign(new Bitstream(), { + id: 'bitstream1', + uuid: 'bitstream1' + }); + const bitstream2 = Object.assign(new Bitstream(), { + id: 'bitstream2', + uuid: 'bitstream2' + }); + const bitstream3 = Object.assign(new Bitstream(), { + id: 'bitstream3', + uuid: 'bitstream3' + }); + const bitstream4 = Object.assign(new Bitstream(), { + id: 'bitstream4', + uuid: 'bitstream4' + }); + const bundle1 = Object.assign(new Bundle(), { + id: 'bundle1', + uuid: 'bundle1', + _links: { + self: { href: 'bundle1-selflink' } + }, + bitstreams: createMockRDPaginatedObs([bitstream1, bitstream2]) + }); + const bundle2 = Object.assign(new Bundle(), { + id: 'bundle2', + uuid: 'bundle2', + _links: { + self: { href: 'bundle2-selflink' } + }, + bitstreams: createMockRDPaginatedObs([bitstream3, bitstream4]) + }); + const bundles = [bundle1, bundle2]; + const bitstreamList1: PaginatedList = new PaginatedList(new PageInfo(), [bitstream1, bitstream2]); + const bitstreamList2: PaginatedList = new PaginatedList(new PageInfo(), [bitstream3, bitstream4]); + + const item = Object.assign(new Item(), { + uuid: 'item', + id: 'item', + _links: { + self: { href: 'item-selflink' } + }, + bundles: createMockRDPaginatedObs([bundle1, bundle2]) + }); + + const routeStub = { + data: observableOf({ + item: createSuccessfulRemoteDataObject(item) + }) + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + TranslateModule.forRoot() + ], + declarations: [ + ItemAuthorizationsComponent, + TestComponent + ], + providers: [ + { provide: LinkService, useValue: linkService }, + { provide: ActivatedRoute, useValue: routeStub }, + ItemAuthorizationsComponent + ], + schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create ItemAuthorizationsComponent', inject([ItemAuthorizationsComponent], (app: ItemAuthorizationsComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ItemAuthorizationsComponent); + comp = fixture.componentInstance; + compAsAny = fixture.componentInstance; + linkService.resolveLink.and.callFake((object, link) => object); + fixture.detectChanges(); + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + it('should init bundles and bitstreams map properly', () => { + expect(compAsAny.subs.length).toBe(2); + expect(compAsAny.bundles$.value).toEqual(bundles); + expect(compAsAny.bundleBitstreamsMap.has('bundle1')).toBeTruthy(); + expect(compAsAny.bundleBitstreamsMap.has('bundle2')).toBeTruthy(); + let bitstreamList = compAsAny.bundleBitstreamsMap.get('bundle1'); + expect(bitstreamList).toBeObservable(cold('(a|)', { + a: bitstreamList1 + })); + + bitstreamList = compAsAny.bundleBitstreamsMap.get('bundle2'); + expect(bitstreamList).toBeObservable(cold('(a|)', { + a: bitstreamList2 + })); + }); + + it('should get the item UUID', () => { + + expect(comp.getItemUUID()).toBeObservable(cold('(a|)', { + a: item.id + })); + + }); + + it('should get the item\'s bundle', () => { + + expect(comp.getItemBundles()).toBeObservable(cold('a', { + a: bundles + })); + + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.ts b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.ts new file mode 100644 index 0000000000..8153990a02 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.ts @@ -0,0 +1,155 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; +import { catchError, filter, first, flatMap, map, take } from 'rxjs/operators'; + +import { PaginatedList } from '../../../core/data/paginated-list'; +import { + getFirstSucceededRemoteDataPayload, + getFirstSucceededRemoteDataWithNotEmptyPayload +} from '../../../core/shared/operators'; +import { Item } from '../../../core/shared/item.model'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { LinkService } from '../../../core/cache/builders/link.service'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { FindListOptions } from '../../../core/data/request.models'; + +/** + * Interface for a bundle's bitstream map entry + */ +interface BundleBitstreamsMapEntry { + id: string; + bitstreams: Observable> +} + +@Component({ + selector: 'ds-item-authorizations', + templateUrl: './item-authorizations.component.html' +}) +/** + * Component that handles the item Authorizations + */ +export class ItemAuthorizationsComponent implements OnInit, OnDestroy { + + /** + * A map that contains all bitstream of the item's bundles + * @type {Observable>>>} + */ + public bundleBitstreamsMap: Map>> = new Map>>(); + + /** + * The list of bundle for the item + * @type {Observable>} + */ + private bundles$: BehaviorSubject = new BehaviorSubject([]); + + /** + * The target editing item + * @type {Observable} + */ + private item$: Observable; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {LinkService} linkService + * @param {ActivatedRoute} route + */ + constructor( + private linkService: LinkService, + private route: ActivatedRoute + ) { + } + + /** + * Initialize the component, setting up the bundle and bitstream within the item + */ + ngOnInit(): void { + this.item$ = this.route.data.pipe( + map((data) => data.item), + getFirstSucceededRemoteDataWithNotEmptyPayload(), + map((item: Item) => this.linkService.resolveLink( + item, + followLink('bundles', new FindListOptions(), true, followLink('bitstreams')) + )) + ) as Observable; + + const bundles$: Observable> = this.item$.pipe( + filter((item: Item) => isNotEmpty(item.bundles)), + flatMap((item: Item) => item.bundles), + getFirstSucceededRemoteDataWithNotEmptyPayload(), + catchError((error) => { + console.error(error); + return observableOf(new PaginatedList(null, [])) + }) + ); + + this.subs.push( + bundles$.pipe( + take(1), + map((list: PaginatedList) => list.page) + ).subscribe((bundles: Bundle[]) => { + this.bundles$.next(bundles); + }), + bundles$.pipe( + take(1), + flatMap((list: PaginatedList) => list.page), + map((bundle: Bundle) => ({ id: bundle.id, bitstreams: this.getBundleBitstreams(bundle) })) + ).subscribe((entry: BundleBitstreamsMapEntry) => { + this.bundleBitstreamsMap.set(entry.id, entry.bitstreams) + }) + ) + } + + /** + * Return the item's UUID + */ + getItemUUID(): Observable { + return this.item$.pipe( + map((item: Item) => item.id), + first((UUID: string) => isNotEmpty(UUID)) + ) + } + + /** + * Return all item's bundles + * + * @return an observable that emits all item's bundles + */ + getItemBundles(): Observable { + return this.bundles$.asObservable(); + } + + /** + * Return all bundle's bitstreams + * + * @return an observable that emits all item's bundles + */ + private getBundleBitstreams(bundle: Bundle): Observable> { + return bundle.bitstreams.pipe( + getFirstSucceededRemoteDataPayload(), + catchError((error) => { + console.error(error); + return observableOf(new PaginatedList(null, [])) + }) + ) + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()) + } +} diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts index e63154918b..1be13e3a7a 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -68,6 +68,7 @@ export class ItemStatusComponent implements OnInit { The value is supposed to be a href for the button */ this.operations = []; + this.operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations')); this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper')); if (item.isWithdrawn) { this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate')); diff --git a/src/app/+item-page/item-page.resolver.ts b/src/app/+item-page/item-page.resolver.ts index 501bb34d2c..63a560778b 100644 --- a/src/app/+item-page/item-page.resolver.ts +++ b/src/app/+item-page/item-page.resolver.ts @@ -7,6 +7,7 @@ import { Item } from '../core/shared/item.model'; import { hasValue } from '../shared/empty.util'; import { find } from 'rxjs/operators'; import { followLink } from '../shared/utils/follow-link-config.model'; +import { FindListOptions } from '../core/data/request.models'; /** * This class represents a resolver that requests a specific item before the route is activated @@ -26,7 +27,7 @@ export class ItemPageResolver implements Resolve> { resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { return this.itemService.findById(route.params.id, followLink('owningCollection'), - followLink('bundles'), + followLink('bundles', new FindListOptions(), true, followLink('bitstreams')), followLink('relationships'), followLink('version', undefined, true, followLink('versionhistory')), ).pipe( diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index f4b02d8774..0ba0851e4e 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -33,7 +33,7 @@ export function getBitstreamModulePath() { return `/${BITSTREAM_MODULE_PATH}`; } -const ADMIN_MODULE_PATH = 'admin'; +export const ADMIN_MODULE_PATH = 'admin'; export function getAdminModulePath() { return `/${ADMIN_MODULE_PATH}`; diff --git a/src/app/core/breadcrumbs/dso-name.service.ts b/src/app/core/breadcrumbs/dso-name.service.ts index 161c4f7254..5567137334 100644 --- a/src/app/core/breadcrumbs/dso-name.service.ts +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -28,7 +28,8 @@ export class DSONameService { return dso.firstMetadataValue('organization.legalName'); }, Default: (dso: DSpaceObject): string => { - return dso.firstMetadataValue('dc.title'); + // If object doesn't have dc.title metadata use name property + return dso.firstMetadataValue('dc.title') || dso.name; } }; diff --git a/src/app/core/cache/models/search-param.model.ts b/src/app/core/cache/models/request-param.model.ts similarity index 86% rename from src/app/core/cache/models/search-param.model.ts rename to src/app/core/cache/models/request-param.model.ts index 3881dbe8b7..ac21fe0b8a 100644 --- a/src/app/core/cache/models/search-param.model.ts +++ b/src/app/core/cache/models/request-param.model.ts @@ -2,7 +2,7 @@ /** * Class representing a query parameter (query?fieldName=fieldValue) used in FindListOptions object */ -export class SearchParam { +export class RequestParam { constructor(public fieldName: string, public fieldValue: any) { } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 356dad5ed8..715f7a5cc0 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -78,7 +78,7 @@ import { RegistryMetadatafieldsResponseParsingService } from './data/registry-me import { RegistryMetadataschemasResponseParsingService } from './data/registry-metadataschemas-response-parsing.service'; import { RelationshipTypeService } from './data/relationship-type.service'; import { RelationshipService } from './data/relationship.service'; -import { ResourcePolicyService } from './data/resource-policy.service'; +import { ResourcePolicyService } from './resource-policy/resource-policy.service'; import { SearchResponseParsingService } from './data/search-response-parsing.service'; import { SiteDataService } from './data/site-data.service'; import { DSpaceRESTv2Service } from './dspace-rest-v2/dspace-rest-v2.service'; @@ -116,7 +116,7 @@ import { RelationshipType } from './shared/item-relationships/relationship-type. import { Relationship } from './shared/item-relationships/relationship.model'; import { Item } from './shared/item.model'; import { License } from './shared/license.model'; -import { ResourcePolicy } from './shared/resource-policy.model'; +import { ResourcePolicy } from './resource-policy/models/resource-policy.model'; import { SearchConfigurationService } from './shared/search/search-configuration.service'; import { SearchFilterService } from './shared/search/search-filter.service'; import { SearchService } from './shared/search/search.service'; diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 6ae40f4ca9..0639a7d8ca 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -12,7 +12,7 @@ import { PaginatedSearchOptions } from '../../shared/search/paginated-search-opt import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { ContentSourceSuccessResponse, RestResponse } from '../cache/response.models'; import { CoreState } from '../core.reducers'; @@ -94,7 +94,7 @@ export class CollectionDataService extends ComColDataService { getAuthorizedCollectionByCommunity(communityId: string, options: FindListOptions = {}): Observable>> { const searchHref = 'findAuthorizedByCommunity'; options = Object.assign({}, options, { - searchParams: [new SearchParam('uuid', communityId)] + searchParams: [new RequestParam('uuid', communityId)] }); return this.searchBy(searchHref, options).pipe( diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 7cbfb2ad03..ca59daa5af 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -20,7 +20,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { getClassForType } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { CacheableObject } from '../cache/object-cache.reducer'; import { ObjectCacheService } from '../cache/object-cache.service'; import { ErrorResponse, RestResponse } from '../cache/response.models'; @@ -111,7 +111,7 @@ export abstract class DataService { result$ = this.getSearchEndpoint(searchMethod); if (hasValue(options.searchParams)) { - options.searchParams.forEach((param: SearchParam) => { + options.searchParams.forEach((param: RequestParam) => { args.push(`${param.fieldName}=${param.fieldValue}`); }) } @@ -153,6 +153,33 @@ export abstract class DataService { } } + /** + * Turn an array of RequestParam into a query string and combine it with the given HREF + * + * @param href The HREF to which the query string should be appended + * @param params Array with additional params to combine with query string + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * + * @return {Observable} + * Return an observable that emits created HREF + */ + protected buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: Array>): string { + + let args = []; + if (hasValue(params)) { + params.forEach((param: RequestParam) => { + args.push(`${param.fieldName}=${param.fieldValue}`); + }) + } + + args = this.addEmbedParams(args, ...linksToFollow); + + if (isNotEmpty(args)) { + return new URLCombiner(href, `?${args.join('&')}`).toString(); + } else { + return href; + } + } /** * Adds the embed options to the link for the request * @param args params for the query string @@ -293,9 +320,9 @@ export abstract class DataService { * @param searchMethod The search method for the object */ protected getSearchEndpoint(searchMethod: string): Observable { - return this.halService.getEndpoint(`${this.linkPath}/search`).pipe( + return this.halService.getEndpoint(this.linkPath).pipe( filter((href: string) => isNotEmpty(href)), - map((href: string) => `${href}/${searchMethod}`)); + map((href: string) => `${href}/search/${searchMethod}`)); } /** @@ -380,15 +407,15 @@ export abstract class DataService { * * @param {DSpaceObject} dso * The object to create - * @param {string} parentUUID - * The UUID of the parent to create the new object under + * @param {RequestParam[]} params + * Array with additional params to combine with query string */ - create(dso: T, parentUUID: string): Observable> { + create(dso: T, ...params: RequestParam[]): Observable> { const requestId = this.requestService.generateRequestId(); const endpoint$ = this.halService.getEndpoint(this.linkPath).pipe( isNotEmptyOperator(), distinctUntilChanged(), - map((endpoint: string) => parentUUID ? `${endpoint}?parent=${parentUUID}` : endpoint) + map((endpoint: string) => this.buildHrefWithParams(endpoint, params)) ); const serializedDso = new DSpaceSerializer(getClassForType((dso as any).type)).serialize(dso); @@ -479,7 +506,7 @@ export abstract class DataService { const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata); return this.requestService.getByUUID(requestId).pipe( - find((request: RequestEntry) => request.completed), + find((request: RequestEntry) => isNotEmpty(request) && request.completed), map((request: RequestEntry) => request.response.isSuccessful) ); } diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts index 4dde567c99..3d68e70206 100644 --- a/src/app/core/data/relationship.service.ts +++ b/src/app/core/data/relationship.service.ts @@ -21,7 +21,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; import { CoreState } from '../core.reducers'; @@ -257,7 +257,7 @@ export class RelationshipService extends DataService { if (options) { findListOptions = Object.assign(new FindListOptions(), options); } - const searchParams = [new SearchParam('label', label), new SearchParam('dso', item.id)]; + const searchParams = [new RequestParam('label', label), new RequestParam('dso', item.id)]; if (findListOptions.searchParams) { findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams]; } else { diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 0655333502..5866cce797 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -11,7 +11,7 @@ import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { SubmissionResponseParsingService } from '../submission/submission-response-parsing.service'; import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service'; import { RestRequestMethod } from './rest-request-method'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { EpersonResponseParsingService } from '../eperson/eperson-response-parsing.service'; import { BrowseItemsResponseParsingService } from './browse-items-response-parsing-service'; import { MetadataschemaParsingService } from './metadataschema-parsing.service'; @@ -146,7 +146,7 @@ export class FindListOptions { elementsPerPage?: number; currentPage?: number; sort?: SortOptions; - searchParams?: SearchParam[]; + searchParams?: RequestParam[]; startsWith?: string; } diff --git a/src/app/core/data/resource-policy.service.spec.ts b/src/app/core/data/resource-policy.service.spec.ts deleted file mode 100644 index abed805ca3..0000000000 --- a/src/app/core/data/resource-policy.service.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { cold, getTestScheduler } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/testing'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ResourcePolicy } from '../shared/resource-policy.model'; -import { RequestService } from './request.service'; -import { ResourcePolicyService } from './resource-policy.service'; - -describe('ResourcePolicyService', () => { - let scheduler: TestScheduler; - let service: ResourcePolicyService; - let requestService: RequestService; - let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; - const testObject = { - uuid: '664184ee-b254-45e8-970d-220e5ccc060b' - } as ResourcePolicy; - const requestURL = `https://rest.api/rest/api/resourcepolicies/${testObject.uuid}`; - const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; - - beforeEach(() => { - scheduler = getTestScheduler(); - - requestService = jasmine.createSpyObj('requestService', { - generateRequestId: requestUUID, - configure: true - }); - rdbService = jasmine.createSpyObj('rdbService', { - buildSingle: cold('a', { - a: { - payload: testObject - } - }) - }); - objectCache = {} as ObjectCacheService; - const halService = {} as HALEndpointService; - const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const comparator = {} as any; - - service = new ResourcePolicyService( - requestService, - rdbService, - objectCache, - halService, - notificationsService, - http, - comparator - ); - - spyOn((service as any).dataService, 'findByHref').and.callThrough(); - }); - - describe('findByHref', () => { - it('should proxy the call to dataservice.findByHref', () => { - scheduler.schedule(() => service.findByHref(requestURL)); - scheduler.flush(); - - expect((service as any).dataService.findByHref).toHaveBeenCalledWith(requestURL); - }); - - it('should return a RemoteData for the object with the given URL', () => { - const result = service.findByHref(requestURL); - const expected = cold('a', { - a: { - payload: testObject - } - }); - expect(result).toBeObservable(expected); - }); - }); -}); diff --git a/src/app/core/data/resource-policy.service.ts b/src/app/core/data/resource-policy.service.ts deleted file mode 100644 index f66032925e..0000000000 --- a/src/app/core/data/resource-policy.service.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; - -import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../cache/builders/build-decorators'; - -import { DataService } from '../data/data.service'; -import { RequestService } from '../data/request.service'; -import { FindListOptions } from '../data/request.models'; -import { Collection } from '../shared/collection.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ResourcePolicy } from '../shared/resource-policy.model'; -import { RemoteData } from '../data/remote-data'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { CoreState } from '../core.reducers'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { RESOURCE_POLICY } from '../shared/resource-policy.resource-type'; -import { ChangeAnalyzer } from './change-analyzer'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; -import { PaginatedList } from './paginated-list'; - -/* tslint:disable:max-classes-per-file */ - -/** - * A private DataService implementation to delegate specific methods to. - */ -class DataServiceImpl extends DataService { - protected linkPath = 'resourcepolicies'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: ChangeAnalyzer) { - super(); - } - -} - -/** - * A service responsible for fetching/sending data from/to the REST API on the resourcepolicies endpoint - */ -@Injectable() -@dataService(RESOURCE_POLICY) -export class ResourcePolicyService { - private dataService: DataServiceImpl; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); - } - - /** - * Returns an observable of {@link RemoteData} of a {@link ResourcePolicy}, based on an href, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the {@link ResourcePolicy} - * @param href The url of {@link ResourcePolicy} we want to retrieve - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - */ - findByHref(href: string, ...linksToFollow: Array>): Observable> { - return this.dataService.findByHref(href, ...linksToFollow); - } - - /** - * Returns a list of observables of {@link RemoteData} of {@link ResourcePolicy}s, based on an href, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the {@link ResourcePolicy} - * @param href The url of the {@link ResourcePolicy} we want to retrieve - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - */ - findAllByHref(href: string, findListOptions: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { - return this.dataService.findAllByHref(href, findListOptions, ...linksToFollow); - } - - /** - * Return the defaultAccessConditions {@link ResourcePolicy} list for a given {@link Collection} - * - * @param collection the {@link Collection} to retrieve the defaultAccessConditions for - * @param findListOptions the {@link FindListOptions} for the request - */ - getDefaultAccessConditionsFor(collection: Collection, findListOptions?: FindListOptions): Observable>> { - return this.dataService.findAllByHref(collection._links.defaultAccessConditions.href, findListOptions); - } -} diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index d83a376da9..0cb56f14a2 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -9,7 +9,7 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs/internal/Observable'; import { TestScheduler } from 'rxjs/testing'; import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from '../../+admin/admin-access-control/epeople-registry/epeople-registry.actions'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { CoreState } from '../core.reducers'; import { ChangeAnalyzer } from '../data/change-analyzer'; import { PaginatedList } from '../data/paginated-list'; @@ -105,7 +105,7 @@ describe('EPersonDataService', () => { it('search by default scope (byMetadata) and no query', () => { service.searchByScope(null, ''); const options = Object.assign(new FindListOptions(), { - searchParams: [Object.assign(new SearchParam('query', ''))] + searchParams: [Object.assign(new RequestParam('query', ''))] }); expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); }); @@ -113,7 +113,7 @@ describe('EPersonDataService', () => { it('search metadata scope and no query', () => { service.searchByScope('metadata', ''); const options = Object.assign(new FindListOptions(), { - searchParams: [Object.assign(new SearchParam('query', ''))] + searchParams: [Object.assign(new RequestParam('query', ''))] }); expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); }); @@ -121,7 +121,7 @@ describe('EPersonDataService', () => { it('search metadata scope and with query', () => { service.searchByScope('metadata', 'test'); const options = Object.assign(new FindListOptions(), { - searchParams: [Object.assign(new SearchParam('query', 'test'))] + searchParams: [Object.assign(new RequestParam('query', 'test'))] }); expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); }); @@ -129,7 +129,7 @@ describe('EPersonDataService', () => { it('search email scope and no query', () => { service.searchByScope('email', ''); const options = Object.assign(new FindListOptions(), { - searchParams: [Object.assign(new SearchParam('email', ''))] + searchParams: [Object.assign(new RequestParam('email', ''))] }); expect(service.searchBy).toHaveBeenCalledWith('byEmail', options); }); diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index a8cee6f1de..86e53178a0 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -15,7 +15,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; import { DataService } from '../data/data.service'; @@ -97,7 +97,7 @@ export class EPersonDataService extends DataService { * @param linksToFollow */ private getEpeopleByEmail(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { - const searchParams = [new SearchParam('email', query)]; + const searchParams = [new RequestParam('email', query)]; return this.getEPeopleBy(searchParams, this.searchByEmailPath, options, ...linksToFollow); } @@ -108,7 +108,7 @@ export class EPersonDataService extends DataService { * @param linksToFollow */ private getEpeopleByMetadata(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { - const searchParams = [new SearchParam('query', query)]; + const searchParams = [new RequestParam('query', query)]; return this.getEPeopleBy(searchParams, this.searchByMetadataPath, options, ...linksToFollow); } @@ -119,7 +119,7 @@ export class EPersonDataService extends DataService { * @param options * @param linksToFollow */ - private getEPeopleBy(searchParams: SearchParam[], searchMethod: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + private getEPeopleBy(searchParams: RequestParam[], searchMethod: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { let findListOptions = new FindListOptions(); if (options) { findListOptions = Object.assign(new FindListOptions(), options); diff --git a/src/app/core/eperson/group-data.service.spec.ts b/src/app/core/eperson/group-data.service.spec.ts index 28d10cfcf1..240e9d6805 100644 --- a/src/app/core/eperson/group-data.service.spec.ts +++ b/src/app/core/eperson/group-data.service.spec.ts @@ -11,7 +11,7 @@ import { GroupRegistryEditGroupAction } from '../../+admin/admin-access-control/group-registry/group-registry.actions'; import { GroupMock, GroupMock2 } from '../../shared/testing/group-mock'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { CoreState } from '../core.reducers'; import { ChangeAnalyzer } from '../data/change-analyzer'; import { PaginatedList } from '../data/paginated-list'; @@ -103,7 +103,7 @@ describe('GroupDataService', () => { it('search with empty query', () => { service.searchGroups(''); const options = Object.assign(new FindListOptions(), { - searchParams: [Object.assign(new SearchParam('query', ''))] + searchParams: [Object.assign(new RequestParam('query', ''))] }); expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); }); @@ -111,7 +111,7 @@ describe('GroupDataService', () => { it('search with query', () => { service.searchGroups('test'); const options = Object.assign(new FindListOptions(), { - searchParams: [Object.assign(new SearchParam('query', 'test'))] + searchParams: [Object.assign(new RequestParam('query', 'test'))] }); expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); }); diff --git a/src/app/core/eperson/group-data.service.ts b/src/app/core/eperson/group-data.service.ts index 574b4d997a..75f00310ec 100644 --- a/src/app/core/eperson/group-data.service.ts +++ b/src/app/core/eperson/group-data.service.ts @@ -14,7 +14,7 @@ import { hasValue } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; import { DataService } from '../data/data.service'; @@ -97,7 +97,7 @@ export class GroupDataService extends DataService { * @param linksToFollow */ public searchGroups(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { - const searchParams = [new SearchParam('query', query)]; + const searchParams = [new RequestParam('query', query)]; let findListOptions = new FindListOptions(); if (options) { findListOptions = Object.assign(new FindListOptions(), options); @@ -121,7 +121,7 @@ export class GroupDataService extends DataService { isMemberOf(groupName: string): Observable { const searchHref = 'isMemberOf'; const options = new FindListOptions(); - options.searchParams = [new SearchParam('groupName', groupName)]; + options.searchParams = [new RequestParam('groupName', groupName)]; return this.searchBy(searchHref, options).pipe( filter((groups: RemoteData>) => !groups.isResponsePending), diff --git a/src/app/core/cache/models/action-type.model.ts b/src/app/core/resource-policy/models/action-type.model.ts similarity index 75% rename from src/app/core/cache/models/action-type.model.ts rename to src/app/core/resource-policy/models/action-type.model.ts index 4965f93e89..93c69c3705 100644 --- a/src/app/core/cache/models/action-type.model.ts +++ b/src/app/core/resource-policy/models/action-type.model.ts @@ -5,27 +5,27 @@ export enum ActionType { /** * Action of reading, viewing or downloading something */ - READ = 0, + READ = 'READ', /** * Action of modifying something */ - WRITE = 1, + WRITE = 'WRITE', /** * Action of deleting something */ - DELETE = 2, + DELETE = 'DELETE', /** * Action of adding something to a container */ - ADD = 3, + ADD = 'ADD', /** * Action of removing something from a container */ - REMOVE = 4, + REMOVE = 'REMOVE', /** * Action of performing workflow step 1 @@ -50,15 +50,20 @@ export enum ActionType { /** * Default Read policies for Bitstreams submitted to container */ - DEFAULT_BITSTREAM_READ = 9, + DEFAULT_BITSTREAM_READ = 'DEFAULT_BITSTREAM_READ', /** * Default Read policies for Items submitted to container */ - DEFAULT_ITEM_READ = 10, + DEFAULT_ITEM_READ = 'DEFAULT_ITEM_READ', /** * Administrative actions */ - ADMIN = 11, + ADMIN = 'ADMIN', + + /** + * Action of withdrawn reading + */ + WITHDRAWN_READ = 'WITHDRAWN_READ' } diff --git a/src/app/core/resource-policy/models/policy-type.model.ts b/src/app/core/resource-policy/models/policy-type.model.ts new file mode 100644 index 0000000000..21193e5ce5 --- /dev/null +++ b/src/app/core/resource-policy/models/policy-type.model.ts @@ -0,0 +1,25 @@ +/** + * Enum representing the Policy Type of a Resource Policy + */ +export enum PolicyType { + /** + * A policy in place during the submission + */ + TYPE_SUBMISSION = 'TYPE_SUBMISSION', + + /** + * A policy in place during the approval workflow + */ + TYPE_WORKFLOW = 'TYPE_WORKFLOW', + + /** + * A policy that has been inherited from a container (the collection) + */ + TYPE_INHERITED = 'TYPE_INHERITED', + + /** + * A policy defined by the user during the submission or workflow phase + */ + TYPE_CUSTOM = 'TYPE_CUSTOM', + +} diff --git a/src/app/core/resource-policy/models/resource-policy.model.ts b/src/app/core/resource-policy/models/resource-policy.model.ts new file mode 100644 index 0000000000..27602557d6 --- /dev/null +++ b/src/app/core/resource-policy/models/resource-policy.model.ts @@ -0,0 +1,105 @@ +import { autoserialize, deserialize, deserializeAs } from 'cerialize'; +import { link, typedObject } from '../../cache/builders/build-decorators'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; +import { ActionType } from './action-type.model'; +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { HALLink } from '../../shared/hal-link.model'; +import { RESOURCE_POLICY } from './resource-policy.resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { PolicyType } from './policy-type.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../data/remote-data'; +import { GROUP } from '../../eperson/models/group.resource-type'; +import { Group } from '../../eperson/models/group.model'; +import { EPERSON } from '../../eperson/models/eperson.resource-type'; +import { EPerson } from '../../eperson/models/eperson.model'; + +/** + * Model class for a Resource Policy + */ +@typedObject +export class ResourcePolicy implements CacheableObject { + static type = RESOURCE_POLICY; + + /** + * The identifier for this Resource Policy + */ + @autoserialize + id: string; + + /** + * The name for this Resource Policy + */ + @autoserialize + name: string; + + /** + * The description for this Resource Policy + */ + @autoserialize + description: string; + + /** + * The classification or this Resource Policy + */ + @autoserialize + policyType: PolicyType; + + /** + * The action that is allowed by this Resource Policy + */ + @autoserialize + action: ActionType; + + /** + * The first day of validity of the policy (format YYYY-MM-DD) + */ + @autoserialize + startDate: string; + + /** + * The last day of validity of the policy (format YYYY-MM-DD) + */ + @autoserialize + endDate: string; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The universally unique identifier for this Resource Policy + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. + */ + @deserializeAs(new IDToUUIDSerializer('resource-policy'), 'id') + uuid: string; + + /** + * The {@link HALLink}s for this ResourcePolicy + */ + @deserialize + _links: { + eperson: HALLink, + group: HALLink, + self: HALLink, + }; + + /** + * The eperson linked by this resource policy + * Will be undefined unless the version {@link HALLink} has been resolved. + */ + @link(EPERSON) + eperson?: Observable>; + + /** + * The group linked by this resource policy + * Will be undefined unless the version {@link HALLink} has been resolved. + */ + @link(GROUP) + group?: Observable>; +} diff --git a/src/app/core/shared/resource-policy.resource-type.ts b/src/app/core/resource-policy/models/resource-policy.resource-type.ts similarity index 52% rename from src/app/core/shared/resource-policy.resource-type.ts rename to src/app/core/resource-policy/models/resource-policy.resource-type.ts index 1811a3a0d1..d8ff3b9485 100644 --- a/src/app/core/shared/resource-policy.resource-type.ts +++ b/src/app/core/resource-policy/models/resource-policy.resource-type.ts @@ -1,4 +1,4 @@ -import { ResourceType } from './resource-type'; +import { ResourceType } from '../../shared/resource-type'; /** * The resource type for ResourcePolicy @@ -6,4 +6,4 @@ import { ResourceType } from './resource-type'; * Needs to be in a separate file to prevent circular * dependencies in webpack. */ -export const RESOURCE_POLICY = new ResourceType('resourcePolicy'); +export const RESOURCE_POLICY = new ResourceType('resourcepolicy'); diff --git a/src/app/core/resource-policy/resource-policy.service.spec.ts b/src/app/core/resource-policy/resource-policy.service.spec.ts new file mode 100644 index 0000000000..1c6ac47405 --- /dev/null +++ b/src/app/core/resource-policy/resource-policy.service.spec.ts @@ -0,0 +1,319 @@ +import { HttpClient } from '@angular/common/http'; + +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from '../data/request.service'; +import { ResourcePolicyService } from './resource-policy.service'; +import { PolicyType } from './models/policy-type.model'; +import { ActionType } from './models/action-type.model'; +import { FindListOptions } from '../data/request.models'; +import { RequestParam } from '../cache/models/request-param.model'; +import { PageInfo } from '../shared/page-info.model'; +import { PaginatedList } from '../data/paginated-list'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { RequestEntry } from '../data/request.reducer'; +import { RestResponse } from '../cache/response.models'; + +describe('ResourcePolicyService', () => { + let scheduler: TestScheduler; + let service: ResourcePolicyService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let responseCacheEntry: RequestEntry; + + const resourcePolicy: any = { + id: '1', + name: null, + description: null, + policyType: PolicyType.TYPE_SUBMISSION, + action: ActionType.READ, + startDate: null, + endDate: null, + type: 'resourcepolicy', + uuid: 'resource-policy-1', + _links: { + eperson: { + href: 'https://rest.api/rest/api/eperson' + }, + group: { + href: 'https://rest.api/rest/api/group' + }, + self: { + href: 'https://rest.api/rest/api/resourcepolicies/1' + }, + } + }; + + const anotherResourcePolicy: any = { + id: '2', + name: null, + description: null, + policyType: PolicyType.TYPE_SUBMISSION, + action: ActionType.WRITE, + startDate: null, + endDate: null, + type: 'resourcepolicy', + uuid: 'resource-policy-2', + _links: { + eperson: { + href: 'https://rest.api/rest/api/eperson' + }, + group: { + href: 'https://rest.api/rest/api/group' + }, + self: { + href: 'https://rest.api/rest/api/resourcepolicies/1' + }, + } + }; + const endpointURL = `https://rest.api/rest/api/resourcepolicies`; + const requestURL = `https://rest.api/rest/api/resourcepolicies/${resourcePolicy.id}`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const resourcePolicyId = '1'; + const epersonUUID = '8b39g7ya-5a4b-438b-9686-be1d5b4a1c5a'; + const groupUUID = '8b39g7ya-5a4b-36987-9686-be1d5b4a1c5a'; + const resourceUUID = '8b39g7ya-5a4b-438b-851f-be1d5b4a1c5a'; + + const pageInfo = new PageInfo(); + const array = [resourcePolicy, anotherResourcePolicy]; + const paginatedList = new PaginatedList(pageInfo, array); + const resourcePolicyRD = createSuccessfulRemoteDataObject(resourcePolicy); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + + beforeEach(() => { + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + configure: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: resourcePolicyRD + }), + buildList: hot('a|', { + a: paginatedListRD + }), + }); + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + + service = new ResourcePolicyService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + http, + comparator + ); + + spyOn((service as any).dataService, 'create').and.callThrough(); + spyOn((service as any).dataService, 'delete').and.callThrough(); + spyOn((service as any).dataService, 'update').and.callThrough(); + spyOn((service as any).dataService, 'findById').and.callThrough(); + spyOn((service as any).dataService, 'findByHref').and.callThrough(); + spyOn((service as any).dataService, 'searchBy').and.callThrough(); + spyOn((service as any).dataService, 'getSearchByHref').and.returnValue(observableOf(requestURL)); + }); + + describe('create', () => { + it('should proxy the call to dataservice.create with eperson UUID', () => { + scheduler.schedule(() => service.create(resourcePolicy, resourceUUID, epersonUUID)); + const params = [ + new RequestParam('resource', resourceUUID), + new RequestParam('eperson', epersonUUID) + ]; + scheduler.flush(); + + expect((service as any).dataService.create).toHaveBeenCalledWith(resourcePolicy, ...params); + }); + + it('should proxy the call to dataservice.create with group UUID', () => { + scheduler.schedule(() => service.create(resourcePolicy, resourceUUID, null, groupUUID)); + const params = [ + new RequestParam('resource', resourceUUID), + new RequestParam('group', groupUUID) + ]; + scheduler.flush(); + + expect((service as any).dataService.create).toHaveBeenCalledWith(resourcePolicy, ...params); + }); + + it('should return a RemoteData for the object with the given id', () => { + const result = service.create(resourcePolicy, resourceUUID, epersonUUID); + const expected = cold('a|', { + a: resourcePolicyRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('delete', () => { + it('should proxy the call to dataservice.create', () => { + scheduler.schedule(() => service.delete(resourcePolicyId)); + scheduler.flush(); + + expect((service as any).dataService.delete).toHaveBeenCalledWith(resourcePolicyId); + }); + }); + + describe('update', () => { + it('should proxy the call to dataservice.update', () => { + scheduler.schedule(() => service.update(resourcePolicy)); + scheduler.flush(); + + expect((service as any).dataService.update).toHaveBeenCalledWith(resourcePolicy); + }); + }); + + describe('findById', () => { + it('should proxy the call to dataservice.findById', () => { + scheduler.schedule(() => service.findById(resourcePolicyId)); + scheduler.flush(); + + expect((service as any).dataService.findById).toHaveBeenCalledWith(resourcePolicyId); + }); + + it('should return a RemoteData for the object with the given id', () => { + const result = service.findById(resourcePolicyId); + const expected = cold('a|', { + a: resourcePolicyRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('findByHref', () => { + it('should proxy the call to dataservice.findByHref', () => { + scheduler.schedule(() => service.findByHref(requestURL)); + scheduler.flush(); + + expect((service as any).dataService.findByHref).toHaveBeenCalledWith(requestURL); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.findByHref(requestURL); + const expected = cold('a|', { + a: resourcePolicyRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('searchByEPerson', () => { + it('should proxy the call to dataservice.searchBy', () => { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('uuid', epersonUUID)]; + scheduler.schedule(() => service.searchByEPerson(epersonUUID)); + scheduler.flush(); + + expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByEPersonMethod, options); + }); + + it('should proxy the call to dataservice.searchBy with additional search param', () => { + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('uuid', epersonUUID), + new RequestParam('resource', resourceUUID), + ]; + scheduler.schedule(() => service.searchByEPerson(epersonUUID, resourceUUID)); + scheduler.flush(); + + expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByEPersonMethod, options); + }); + + it('should return a RemoteData) for the search', () => { + const result = service.searchByEPerson(epersonUUID, resourceUUID); + const expected = cold('a|', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + + }); + + describe('searchByGroup', () => { + it('should proxy the call to dataservice.searchBy', () => { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('uuid', groupUUID)]; + scheduler.schedule(() => service.searchByGroup(groupUUID)); + scheduler.flush(); + + expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByGroupMethod, options); + }); + + it('should proxy the call to dataservice.searchBy with additional search param', () => { + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('uuid', groupUUID), + new RequestParam('resource', resourceUUID), + ]; + scheduler.schedule(() => service.searchByGroup(groupUUID, resourceUUID)); + scheduler.flush(); + + expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByGroupMethod, options); + }); + + it('should return a RemoteData) for the search', () => { + const result = service.searchByGroup(groupUUID); + const expected = cold('a|', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + + }); + + describe('searchByResource', () => { + it('should proxy the call to dataservice.searchBy', () => { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('uuid', resourceUUID)]; + scheduler.schedule(() => service.searchByResource(resourceUUID)); + scheduler.flush(); + + expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByResourceMethod, options); + }); + + it('should proxy the call to dataservice.searchBy with additional search param', () => { + const action = ActionType.READ; + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('uuid', resourceUUID), + new RequestParam('action', action), + ]; + scheduler.schedule(() => service.searchByResource(resourceUUID, action)); + scheduler.flush(); + + expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByResourceMethod, options); + }); + + it('should return a RemoteData) for the search', () => { + const result = service.searchByResource(resourceUUID); + const expected = cold('a|', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/core/resource-policy/resource-policy.service.ts b/src/app/core/resource-policy/resource-policy.service.ts new file mode 100644 index 0000000000..291920c35a --- /dev/null +++ b/src/app/core/resource-policy/resource-policy.service.ts @@ -0,0 +1,193 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { dataService } from '../cache/builders/build-decorators'; + +import { DataService } from '../data/data.service'; +import { RequestService } from '../data/request.service'; +import { FindListOptions } from '../data/request.models'; +import { Collection } from '../shared/collection.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ResourcePolicy } from './models/resource-policy.model'; +import { RemoteData } from '../data/remote-data'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RESOURCE_POLICY } from './models/resource-policy.resource-type'; +import { ChangeAnalyzer } from '../data/change-analyzer'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { PaginatedList } from '../data/paginated-list'; +import { ActionType } from './models/action-type.model'; +import { RequestParam } from '../cache/models/request-param.model'; +import { isNotEmpty } from '../../shared/empty.util'; + +/* tslint:disable:max-classes-per-file */ + +/** + * A private DataService implementation to delegate specific methods to. + */ +class DataServiceImpl extends DataService { + protected linkPath = 'resourcepolicies'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: ChangeAnalyzer) { + super(); + } + +} + +/** + * A service responsible for fetching/sending data from/to the REST API on the resourcepolicies endpoint + */ +@Injectable() +@dataService(RESOURCE_POLICY) +export class ResourcePolicyService { + private dataService: DataServiceImpl; + protected searchByEPersonMethod = 'eperson'; + protected searchByGroupMethod = 'group'; + protected searchByResourceMethod = 'resource'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + } + + /** + * Create a new ResourcePolicy on the server, and store the response + * in the object cache + * + * @param {ResourcePolicy} resourcePolicy + * The resource policy to create + * @param {string} resourceUUID + * The uuid of the resource target of the policy + * @param {string} epersonUUID + * The uuid of the eperson that will be grant of the permission. Exactly one of eperson or group is required + * @param {string} groupUUID + * The uuid of the group that will be grant of the permission. Exactly one of eperson or group is required + */ + create(resourcePolicy: ResourcePolicy, resourceUUID: string, epersonUUID?: string, groupUUID?: string): Observable> { + const params = []; + params.push(new RequestParam('resource', resourceUUID)); + if (isNotEmpty(epersonUUID)) { + params.push(new RequestParam('eperson', epersonUUID)); + } else if (isNotEmpty(groupUUID)) { + params.push(new RequestParam('group', groupUUID)); + } + return this.dataService.create(resourcePolicy, ...params); + } + + /** + * Delete an existing ResourcePolicy on the server + * + * @param resourcePolicyID The resource policy's id to be removed + * @return an observable that emits true when the deletion was successful, false when it failed + */ + delete(resourcePolicyID: string): Observable { + return this.dataService.delete(resourcePolicyID); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {ResourcePolicy} object The given object + */ + update(object: ResourcePolicy): Observable> { + return this.dataService.update(object); + } + + /** + * Returns an observable of {@link RemoteData} of a {@link ResourcePolicy}, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link ResourcePolicy} + * @param href The url of {@link ResourcePolicy} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findByHref(href: string, ...linksToFollow: Array>): Observable> { + return this.dataService.findByHref(href, ...linksToFollow); + } + + /** + * Returns an observable of {@link RemoteData} of a {@link ResourcePolicy}, based on its ID, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the object + * @param id ID of {@link ResourcePolicy} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findById(id: string, ...linksToFollow: Array>): Observable> { + return this.dataService.findById(id, ...linksToFollow); + } + + /** + * Return the defaultAccessConditions {@link ResourcePolicy} list for a given {@link Collection} + * + * @param collection the {@link Collection} to retrieve the defaultAccessConditions for + * @param findListOptions the {@link FindListOptions} for the request + */ + getDefaultAccessConditionsFor(collection: Collection, findListOptions?: FindListOptions): Observable>> { + return this.dataService.findAllByHref(collection._links.defaultAccessConditions.href, findListOptions); + } + + /** + * Return the {@link ResourcePolicy} list for a {@link EPerson} + * + * @param UUID UUID of a given {@link EPerson} + * @param resourceUUID Limit the returned policies to the specified DSO + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + searchByEPerson(UUID: string, resourceUUID?: string, ...linksToFollow: Array>): Observable>> { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('uuid', UUID)]; + if (isNotEmpty(resourceUUID)) { + options.searchParams.push(new RequestParam('resource', resourceUUID)) + } + return this.dataService.searchBy(this.searchByEPersonMethod, options, ...linksToFollow) + } + + /** + * Return the {@link ResourcePolicy} list for a {@link Group} + * + * @param UUID UUID of a given {@link Group} + * @param resourceUUID Limit the returned policies to the specified DSO + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + searchByGroup(UUID: string, resourceUUID?: string, ...linksToFollow: Array>): Observable>> { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('uuid', UUID)]; + if (isNotEmpty(resourceUUID)) { + options.searchParams.push(new RequestParam('resource', resourceUUID)) + } + return this.dataService.searchBy(this.searchByGroupMethod, options, ...linksToFollow) + } + + /** + * Return the {@link ResourcePolicy} list for a given DSO + * + * @param UUID UUID of a given DSO + * @param action Limit the returned policies to the specified {@link ActionType} + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + searchByResource(UUID: string, action?: ActionType, ...linksToFollow: Array>): Observable>> { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('uuid', UUID)]; + if (isNotEmpty(action)) { + options.searchParams.push(new RequestParam('action', action)) + } + return this.dataService.searchBy(this.searchByResourceMethod, options, ...linksToFollow) + } + +} diff --git a/src/app/core/shared/bundle.model.ts b/src/app/core/shared/bundle.model.ts index c1164f0fc4..1e5c14d486 100644 --- a/src/app/core/shared/bundle.model.ts +++ b/src/app/core/shared/bundle.model.ts @@ -1,8 +1,15 @@ import { deserialize, inheritSerialization } from 'cerialize'; -import { typedObject } from '../cache/builders/build-decorators'; + +import { Observable } from 'rxjs'; + +import { link, typedObject } from '../cache/builders/build-decorators'; import { BUNDLE } from './bundle.resource-type'; import { DSpaceObject } from './dspace-object.model'; import { HALLink } from './hal-link.model'; +import { RemoteData } from '../data/remote-data'; +import { PaginatedList } from '../data/paginated-list'; +import { BITSTREAM } from './bitstream.resource-type'; +import { Bitstream } from './bitstream.model'; @typedObject @inheritSerialization(DSpaceObject) @@ -17,5 +24,19 @@ export class Bundle extends DSpaceObject { self: HALLink; primaryBitstream: HALLink; bitstreams: HALLink; - } + }; + + /** + * The primary Bitstream of this Bundle + * Will be undefined unless the primaryBitstream {@link HALLink} has been resolved. + */ + @link(BITSTREAM) + primaryBitstream?: Observable>; + + /** + * The list of Bitstreams that are direct children of this Bundle + * Will be undefined unless the bitstreams {@link HALLink} has been resolved. + */ + @link(BITSTREAM, true) + bitstreams?: Observable>>; } diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts index 4e0b5ead83..b65ac252ef 100644 --- a/src/app/core/shared/collection.model.ts +++ b/src/app/core/shared/collection.model.ts @@ -10,8 +10,8 @@ import { DSpaceObject } from './dspace-object.model'; import { HALLink } from './hal-link.model'; import { License } from './license.model'; import { LICENSE } from './license.resource-type'; -import { ResourcePolicy } from './resource-policy.model'; -import { RESOURCE_POLICY } from './resource-policy.resource-type'; +import { ResourcePolicy } from '../resource-policy/models/resource-policy.model'; +import { RESOURCE_POLICY } from '../resource-policy/models/resource-policy.resource-type'; import { COMMUNITY } from './community.resource-type'; import { Community } from './community.model'; import { ChildHALResource } from './child-hal-resource.model'; diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index a51e711d26..a307b144d2 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -67,6 +67,10 @@ export const getSucceededRemoteData = () => (source: Observable>): Observable> => source.pipe(find((rd: RemoteData) => rd.hasSucceeded)); +export const getSucceededRemoteWithNotEmptyData = () => + (source: Observable>): Observable> => + source.pipe(find((rd: RemoteData) => rd.hasSucceeded && isNotEmpty(rd.payload))); + /** * Get the first successful remotely retrieved object * @@ -84,6 +88,23 @@ export const getFirstSucceededRemoteDataPayload = () => getRemoteDataPayload() ); +/** + * Get the first successful remotely retrieved object with not empty payload + * + * You usually don't want to use this, it is a code smell. + * Work with the RemoteData object instead, that way you can + * handle loading and errors correctly. + * + * These operators were created as a first step in refactoring + * out all the instances where this is used incorrectly. + */ +export const getFirstSucceededRemoteDataWithNotEmptyPayload = () => + (source: Observable>): Observable => + source.pipe( + getSucceededRemoteWithNotEmptyData(), + getRemoteDataPayload() + ); + /** * Get the all successful remotely retrieved objects * diff --git a/src/app/core/shared/resource-policy.model.ts b/src/app/core/shared/resource-policy.model.ts deleted file mode 100644 index dd00a16e97..0000000000 --- a/src/app/core/shared/resource-policy.model.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { autoserialize, deserialize, deserializeAs } from 'cerialize'; -import { typedObject } from '../cache/builders/build-decorators'; -import { IDToUUIDSerializer } from '../cache/id-to-uuid-serializer'; -import { ActionType } from '../cache/models/action-type.model'; -import { CacheableObject } from '../cache/object-cache.reducer'; -import { excludeFromEquals } from '../utilities/equals.decorators'; -import { HALLink } from './hal-link.model'; -import { RESOURCE_POLICY } from './resource-policy.resource-type'; -import { ResourceType } from './resource-type'; - -/** - * Model class for a Resource Policy - */ -@typedObject -export class ResourcePolicy implements CacheableObject { - static type = RESOURCE_POLICY; - - /** - * The object type - */ - @excludeFromEquals - @autoserialize - type: ResourceType; - - /** - * The action that is allowed by this Resource Policy - */ - @autoserialize - action: ActionType; - - /** - * The name for this Resource Policy - */ - @autoserialize - name: string; - - /** - * The uuid of the Group this Resource Policy applies to - */ - @autoserialize - groupUUID: string; - - /** - * The universally unique identifier for this Resource Policy - * This UUID is generated client-side and isn't used by the backend. - * It is based on the ID, so it will be the same for each refresh. - */ - @deserializeAs(new IDToUUIDSerializer('resource-policy'), 'id') - uuid: string; - - /** - * The {@link HALLink}s for this ResourcePolicy - */ - @deserialize - _links: { - self: HALLink, - } -} diff --git a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts index e9373aff47..a8d6499cbd 100644 --- a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts @@ -13,6 +13,7 @@ import { getSucceededRemoteData } from '../../../core/shared/operators'; import { ResourceType } from '../../../core/shared/resource-type'; import { hasValue, isNotEmpty, isNotUndefined } from '../../empty.util'; import { NotificationsService } from '../../notifications/notifications.service'; +import { RequestParam } from '../../../core/cache/models/request-param.model'; /** * Component representing the create page for communities and collections @@ -76,7 +77,7 @@ export class CreateComColPageComponent implements const uploader = event.uploader; this.parentUUID$.pipe(take(1)).subscribe((uuid: string) => { - this.dsoDataService.create(dso, uuid) + this.dsoDataService.create(dso, new RequestParam('parent', uuid)) .pipe(getSucceededRemoteData()) .subscribe((dsoRD: RemoteData) => { if (isNotUndefined(dsoRD)) { diff --git a/src/app/shared/date.util.ts b/src/app/shared/date.util.ts index 5e91871967..afbdabc856 100644 --- a/src/app/shared/date.util.ts +++ b/src/app/shared/date.util.ts @@ -3,6 +3,8 @@ import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap'; import { isObject } from 'lodash'; import * as moment from 'moment'; +import { isNull } from './empty.util'; + /** * Returns true if the passed value is a NgbDateStruct. * @@ -13,7 +15,7 @@ import * as moment from 'moment'; */ export function isNgbDateStruct(value: object): boolean { return isObject(value) && value.hasOwnProperty('day') - && value.hasOwnProperty('month') && value.hasOwnProperty('year'); + && value.hasOwnProperty('month') && value.hasOwnProperty('year'); } /** @@ -56,3 +58,57 @@ export function dateToISOFormat(date: Date | NgbDateStruct): string { export function ngbDateStructToDate(date: NgbDateStruct): Date { return new Date(date.year, (date.month - 1), date.day); } + +/** + * Returns a NgbDateStruct object started from a string representing a date + * + * @param date + * The Date to convert + * @return NgbDateStruct + * the NgbDateStruct object + */ +export function stringToNgbDateStruct(date: string): NgbDateStruct { + return dateToNgbDateStruct(new Date(date)); +} + +/** + * Returns a NgbDateStruct object started from a Date object + * + * @param date + * The Date to convert + * @return NgbDateStruct + * the NgbDateStruct object + */ +export function dateToNgbDateStruct(date?: Date): NgbDateStruct { + if (isNull(date)) { + date = new Date() + } + + return { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate() + }; +} + +/** + * Returns a date in simplified format (YYYY-MM-DD). + * + * @param date + * The date to format + * @return string + * the formatted date + */ +export function dateToString(date: Date | NgbDateStruct): string { + const dateObj: Date = (date instanceof Date) ? date : ngbDateStructToDate(date); + + let year = dateObj.getFullYear().toString(); + let month = (dateObj.getMonth() + 1).toString(); + let day = dateObj.getDate().toString(); + + year = (year.length === 1) ? '0' + year : year; + month = (month.length === 1) ? '0' + month : month; + day = (day.length === 1) ? '0' + day : day; + const dateStr = `${year}-${month}-${day}`; + return moment.utc(dateStr, 'YYYYMMDD').format('YYYY-MM-DD'); +} diff --git a/src/app/shared/mocks/mock-resource-policy-service.ts b/src/app/shared/mocks/mock-resource-policy-service.ts new file mode 100644 index 0000000000..864cf20730 --- /dev/null +++ b/src/app/shared/mocks/mock-resource-policy-service.ts @@ -0,0 +1,10 @@ +import { ResourcePolicyService } from '../../core/resource-policy/resource-policy.service'; + +export function getMockResourcePolicyService(): ResourcePolicyService { + return jasmine.createSpyObj('resourcePolicyService', { + searchByResource: jasmine.createSpy('searchByResource'), + create: jasmine.createSpy('create'), + delete: jasmine.createSpy('delete'), + update: jasmine.createSpy('update') + }); +} diff --git a/src/app/shared/ng-for-track-by-id.directive.ts b/src/app/shared/ng-for-track-by-id.directive.ts new file mode 100644 index 0000000000..22343df750 --- /dev/null +++ b/src/app/shared/ng-for-track-by-id.directive.ts @@ -0,0 +1,16 @@ +import { Directive, Host } from '@angular/core'; +import { NgForOf } from '@angular/common'; + +import { DSpaceObject } from '../core/shared/dspace-object.model'; + +@Directive({ + // tslint:disable-next-line:directive-selector + selector: '[ngForTrackById]', +}) +export class NgForTrackByIdDirective { + + constructor(@Host() private ngFor: NgForOf) { + this.ngFor.ngForTrackBy = (index: number, dso: T) => (dso) ? dso.id : undefined; + } + +} diff --git a/src/app/shared/resource-policies/create/resource-policy-create.component.html b/src/app/shared/resource-policies/create/resource-policy-create.component.html new file mode 100644 index 0000000000..85d0d13e96 --- /dev/null +++ b/src/app/shared/resource-policies/create/resource-policy-create.component.html @@ -0,0 +1,7 @@ +
+

{{'resource-policies.create.page.heading' | translate}} {{targetResourceName}}

+ + +
diff --git a/src/app/shared/resource-policies/create/resource-policy-create.component.spec.ts b/src/app/shared/resource-policies/create/resource-policy-create.component.spec.ts new file mode 100644 index 0000000000..1c41280bab --- /dev/null +++ b/src/app/shared/resource-policies/create/resource-policy-create.component.spec.ts @@ -0,0 +1,265 @@ +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { ChangeDetectorRef, Component, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject +} from '../../remote-data.utils'; +import { createTestComponent } from '../../testing/utils.test'; +import { ResourcePolicyCreateComponent } from './resource-policy-create.component'; +import { LinkService } from '../../../core/cache/builders/link.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; +import { ResourcePolicyService } from '../../../core/resource-policy/resource-policy.service'; +import { getMockResourcePolicyService } from '../../mocks/mock-resource-policy-service'; +import { getMockLinkService } from '../../mocks/link-service.mock'; +import { RouterStub } from '../../testing/router.stub'; +import { Item } from '../../../core/shared/item.model'; +import { createMockRDPaginatedObs } from '../../../+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec'; +import { ResourcePolicyEvent } from '../form/resource-policy-form.component'; +import { GroupMock } from '../../testing/group-mock'; +import { submittedResourcePolicy } from '../form/resource-policy-form.component.spec'; +import { PolicyType } from '../../../core/resource-policy/models/policy-type.model'; +import { ActionType } from '../../../core/resource-policy/models/action-type.model'; +import { EPersonMock } from '../../testing/eperson.mock'; + +describe('ResourcePolicyCreateComponent test suite', () => { + let comp: ResourcePolicyCreateComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let de; + let scheduler: TestScheduler; + let eventPayload: ResourcePolicyEvent; + + const resourcePolicy: any = { + id: '1', + name: null, + description: null, + policyType: PolicyType.TYPE_SUBMISSION, + action: ActionType.READ, + startDate: null, + endDate: null, + type: 'resourcepolicy', + uuid: 'resource-policy-1', + _links: { + eperson: { + href: 'https://rest.api/rest/api/eperson' + }, + group: { + href: 'https://rest.api/rest/api/group' + }, + self: { + href: 'https://rest.api/rest/api/resourcepolicies/1' + }, + }, + eperson: observableOf(createSuccessfulRemoteDataObject({})), + group: observableOf(createSuccessfulRemoteDataObject(GroupMock)) + }; + + const item = Object.assign(new Item(), { + uuid: 'itemUUID', + id: 'itemUUID', + metadata: { + 'dc.title': [{ + value: 'test item' + }] + }, + _links: { + self: { href: 'item-selflink' } + }, + bundles: createMockRDPaginatedObs([]) + }); + + const resourcePolicyService: any = getMockResourcePolicyService(); + const linkService: any = getMockLinkService(); + const routeStub = { + data: observableOf({ + resourcePolicyTarget: createSuccessfulRemoteDataObject(item) + }) + }; + const routerStub = Object.assign(new RouterStub(), { + url: `url/edit` + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot() + ], + declarations: [ + ResourcePolicyCreateComponent, + TestComponent + ], + providers: [ + { provide: LinkService, useValue: linkService }, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: ResourcePolicyService, useValue: resourcePolicyService }, + { provide: Router, useValue: routerStub }, + ResourcePolicyCreateComponent, + ChangeDetectorRef, + Injector + ], + schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create ResourcePolicyCreateComponent', inject([ResourcePolicyCreateComponent], (app: ResourcePolicyCreateComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + + beforeEach(() => { + // initTestScheduler(); + fixture = TestBed.createComponent(ResourcePolicyCreateComponent); + comp = fixture.componentInstance; + compAsAny = fixture.componentInstance; + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + it('should init component properly', () => { + fixture.detectChanges(); + expect(compAsAny.targetResourceUUID).toBe('itemUUID'); + expect(compAsAny.targetResourceName).toBe('test item'); + }); + + it('should redirect to authorizations page', () => { + comp.redirectToAuthorizationsPage(); + expect(compAsAny.router.navigate).toHaveBeenCalled(); + }); + + it('should return true when is Processing', () => { + compAsAny.processing$.next(true); + expect(comp.isProcessing()).toBeObservable(cold('a', { + a: true + })); + }); + + it('should return false when is not Processing', () => { + compAsAny.processing$.next(false); + expect(comp.isProcessing()).toBeObservable(cold('a', { + a: false + })); + }); + + describe('when target type is group', () => { + beforeEach(() => { + spyOn(comp, 'redirectToAuthorizationsPage').and.callThrough(); + + compAsAny.targetResourceUUID = 'itemUUID'; + + eventPayload = Object.create({}); + eventPayload.object = submittedResourcePolicy; + eventPayload.target = { + type: 'group', + uuid: GroupMock.id + }; + }); + + it('should notify success when creation is successful', () => { + compAsAny.resourcePolicyService.create.and.returnValue(observableOf(createSuccessfulRemoteDataObject(resourcePolicy))); + + scheduler = getTestScheduler(); + scheduler.schedule(() => comp.createResourcePolicy(eventPayload)); + scheduler.flush(); + + expect(compAsAny.resourcePolicyService.create).toHaveBeenCalledWith(eventPayload.object, 'itemUUID', null, eventPayload.target.uuid); + expect(comp.redirectToAuthorizationsPage).toHaveBeenCalled(); + }); + + it('should notify error when creation is not successful', () => { + compAsAny.resourcePolicyService.create.and.returnValue(observableOf(createFailedRemoteDataObject({}))); + + scheduler = getTestScheduler(); + scheduler.schedule(() => comp.createResourcePolicy(eventPayload)); + scheduler.flush(); + + expect(compAsAny.resourcePolicyService.create).toHaveBeenCalledWith(eventPayload.object, 'itemUUID', null, eventPayload.target.uuid); + expect(comp.redirectToAuthorizationsPage).not.toHaveBeenCalled(); + }); + }); + + describe('when target type of created policy is eperson', () => { + + beforeEach(() => { + spyOn(comp, 'redirectToAuthorizationsPage').and.callThrough(); + + compAsAny.targetResourceUUID = 'itemUUID'; + + eventPayload = Object.create({}); + eventPayload.object = submittedResourcePolicy; + eventPayload.target = { + type: 'eperson', + uuid: EPersonMock.id + }; + }); + + it('should notify success when creation is successful', () => { + compAsAny.resourcePolicyService.create.and.returnValue(observableOf(createSuccessfulRemoteDataObject(resourcePolicy))); + + scheduler = getTestScheduler(); + scheduler.schedule(() => comp.createResourcePolicy(eventPayload)); + scheduler.flush(); + + expect(compAsAny.resourcePolicyService.create).toHaveBeenCalledWith(eventPayload.object, 'itemUUID', eventPayload.target.uuid); + expect(comp.redirectToAuthorizationsPage).toHaveBeenCalled(); + }); + + it('should notify error when creation is not successful', () => { + compAsAny.resourcePolicyService.create.and.returnValue(observableOf(createFailedRemoteDataObject({}))); + + scheduler = getTestScheduler(); + scheduler.schedule(() => comp.createResourcePolicy(eventPayload)); + scheduler.flush(); + + expect(compAsAny.resourcePolicyService.create).toHaveBeenCalledWith(eventPayload.object, 'itemUUID', eventPayload.target.uuid); + expect(comp.redirectToAuthorizationsPage).not.toHaveBeenCalled(); + }); + }); + }); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/shared/resource-policies/create/resource-policy-create.component.ts b/src/app/shared/resource-policies/create/resource-policy-create.component.ts new file mode 100644 index 0000000000..e96533515c --- /dev/null +++ b/src/app/shared/resource-policies/create/resource-policy-create.component.ts @@ -0,0 +1,112 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { BehaviorSubject, Observable } from 'rxjs'; +import { first, map, take } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; + +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { ResourcePolicyService } from '../../../core/resource-policy/resource-policy.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ResourcePolicy } from '../../../core/resource-policy/models/resource-policy.model'; +import { ResourcePolicyEvent } from '../form/resource-policy-form.component'; +import { ITEM_EDIT_AUTHORIZATIONS_PATH } from '../../../+item-page/edit-item-page/edit-item-page.routing.module'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; + +@Component({ + selector: 'ds-resource-policy-create', + templateUrl: './resource-policy-create.component.html' +}) +export class ResourcePolicyCreateComponent implements OnInit { + + /** + * The name of the resource target of the policy + */ + public targetResourceName: string; + + /** + * A boolean representing if a submission creation operation is pending + * @type {BehaviorSubject} + */ + private processing$ = new BehaviorSubject(false); + + /** + * The uuid of the resource target of the policy + */ + private targetResourceUUID: string; + + /** + * Initialize instance variables + * + * @param {DSONameService} dsoNameService + * @param {NotificationsService} notificationsService + * @param {ResourcePolicyService} resourcePolicyService + * @param {ActivatedRoute} route + * @param {Router} router + * @param {TranslateService} translate + */ + constructor( + private dsoNameService: DSONameService, + private notificationsService: NotificationsService, + private resourcePolicyService: ResourcePolicyService, + private route: ActivatedRoute, + private router: Router, + private translate: TranslateService) { + } + + /** + * Initialize the component + */ + ngOnInit(): void { + this.route.data.pipe( + map((data) => data), + take(1) + ).subscribe((data: any) => { + this.targetResourceUUID = (data.resourcePolicyTarget as RemoteData).payload.id; + this.targetResourceName = this.dsoNameService.getName((data.resourcePolicyTarget as RemoteData).payload); + }); + } + + /** + * Return a boolean representing if an operation is pending + * + * @return {Observable} + */ + isProcessing(): Observable { + return this.processing$.asObservable(); + } + + /** + * Redirect to the authorizations page + */ + redirectToAuthorizationsPage(): void { + this.router.navigate([`../../${ITEM_EDIT_AUTHORIZATIONS_PATH}`], { relativeTo: this.route }); + } + + /** + * Create a new resource policy + * + * @param event The {{ResourcePolicyEvent}} emitted + */ + createResourcePolicy(event: ResourcePolicyEvent): void { + this.processing$.next(true); + let response$; + if (event.target.type === 'eperson') { + response$ = this.resourcePolicyService.create(event.object, this.targetResourceUUID, event.target.uuid); + } else { + response$ = this.resourcePolicyService.create(event.object, this.targetResourceUUID, null, event.target.uuid); + } + response$.pipe( + first((response: RemoteData) => !response.isResponsePending) + ).subscribe((responseRD: RemoteData) => { + this.processing$.next(false); + if (responseRD.hasSucceeded) { + this.notificationsService.success(null, this.translate.get('resource-policies.create.page.success.content')); + this.redirectToAuthorizationsPage(); + } else { + this.notificationsService.error(null, this.translate.get('resource-policies.create.page.failure.content')); + } + }) + } +} diff --git a/src/app/shared/resource-policies/edit/resource-policy-edit.component.html b/src/app/shared/resource-policies/edit/resource-policy-edit.component.html new file mode 100644 index 0000000000..0f285c4948 --- /dev/null +++ b/src/app/shared/resource-policies/edit/resource-policy-edit.component.html @@ -0,0 +1,8 @@ +
+

{{'resource-policies.edit.page.heading' | translate}} {{resourcePolicy.id}}

+ + +
diff --git a/src/app/shared/resource-policies/edit/resource-policy-edit.component.spec.ts b/src/app/shared/resource-policies/edit/resource-policy-edit.component.spec.ts new file mode 100644 index 0000000000..b124da0219 --- /dev/null +++ b/src/app/shared/resource-policies/edit/resource-policy-edit.component.spec.ts @@ -0,0 +1,220 @@ +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { ChangeDetectorRef, Component, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject +} from '../../remote-data.utils'; +import { createTestComponent } from '../../testing/utils.test'; +import { LinkService } from '../../../core/cache/builders/link.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; +import { ResourcePolicyService } from '../../../core/resource-policy/resource-policy.service'; +import { getMockResourcePolicyService } from '../../mocks/mock-resource-policy-service'; +import { getMockLinkService } from '../../mocks/link-service.mock'; +import { RouterStub } from '../../testing/router.stub'; +import { ResourcePolicyEvent } from '../form/resource-policy-form.component'; +import { GroupMock } from '../../testing/group-mock'; +import { submittedResourcePolicy } from '../form/resource-policy-form.component.spec'; +import { PolicyType } from '../../../core/resource-policy/models/policy-type.model'; +import { ActionType } from '../../../core/resource-policy/models/action-type.model'; +import { ResourcePolicyEditComponent } from './resource-policy-edit.component'; +import { RESOURCE_POLICY } from '../../../core/resource-policy/models/resource-policy.resource-type'; + +describe('ResourcePolicyEditComponent test suite', () => { + let comp: ResourcePolicyEditComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let de; + let scheduler: TestScheduler; + let eventPayload: ResourcePolicyEvent; + let updatedObject; + + const resourcePolicy: any = { + id: '1', + name: null, + description: null, + policyType: PolicyType.TYPE_SUBMISSION, + action: ActionType.READ, + startDate: null, + endDate: null, + type: 'resourcepolicy', + uuid: 'resource-policy-1', + _links: { + eperson: { + href: 'https://rest.api/rest/api/eperson' + }, + group: { + href: 'https://rest.api/rest/api/group' + }, + self: { + href: 'https://rest.api/rest/api/resourcepolicies/1' + }, + }, + eperson: observableOf(createSuccessfulRemoteDataObject({})), + group: observableOf(createSuccessfulRemoteDataObject(GroupMock)) + }; + + const resourcePolicyService: any = getMockResourcePolicyService(); + const linkService: any = getMockLinkService(); + const routeStub = { + data: observableOf({ + resourcePolicy: createSuccessfulRemoteDataObject(resourcePolicy) + }) + }; + const routerStub = Object.assign(new RouterStub(), { + url: `url/edit` + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot() + ], + declarations: [ + ResourcePolicyEditComponent, + TestComponent + ], + providers: [ + { provide: LinkService, useValue: linkService }, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: ResourcePolicyService, useValue: resourcePolicyService }, + { provide: Router, useValue: routerStub }, + ResourcePolicyEditComponent, + ChangeDetectorRef, + Injector + ], + schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create ResourcePolicyEditComponent', inject([ResourcePolicyEditComponent], (app: ResourcePolicyEditComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + + beforeEach(() => { + // initTestScheduler(); + fixture = TestBed.createComponent(ResourcePolicyEditComponent); + comp = fixture.componentInstance; + compAsAny = fixture.componentInstance; + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + it('should init component properly', () => { + fixture.detectChanges(); + expect(compAsAny.resourcePolicy).toEqual(resourcePolicy); + }); + + it('should redirect to authorizations page', () => { + comp.redirectToAuthorizationsPage(); + expect(compAsAny.router.navigate).toHaveBeenCalled(); + }); + + it('should return true when is Processing', () => { + compAsAny.processing$.next(true); + expect(comp.isProcessing()).toBeObservable(cold('a', { + a: true + })); + }); + + it('should return false when is not Processing', () => { + compAsAny.processing$.next(false); + expect(comp.isProcessing()).toBeObservable(cold('a', { + a: false + })); + }); + + describe('', () => { + beforeEach(() => { + spyOn(comp, 'redirectToAuthorizationsPage').and.callThrough(); + compAsAny.resourcePolicyService.update.and.returnValue(observableOf(createSuccessfulRemoteDataObject(resourcePolicy))); + + compAsAny.targetResourceUUID = 'itemUUID'; + + eventPayload = Object.create({}); + eventPayload.object = submittedResourcePolicy; + eventPayload.target = { + type: 'group', + uuid: GroupMock.id + }; + + compAsAny.resourcePolicy = resourcePolicy; + + updatedObject = Object.assign({}, submittedResourcePolicy, { + id: resourcePolicy.id, + type: RESOURCE_POLICY.value, + _links: resourcePolicy._links + }); + }); + + it('should notify success when update is successful', () => { + compAsAny.resourcePolicyService.update.and.returnValue(observableOf(createSuccessfulRemoteDataObject(resourcePolicy))); + + scheduler = getTestScheduler(); + scheduler.schedule(() => comp.updateResourcePolicy(eventPayload)); + scheduler.flush(); + + expect(compAsAny.resourcePolicyService.update).toHaveBeenCalledWith(updatedObject); + expect(comp.redirectToAuthorizationsPage).toHaveBeenCalled(); + }); + + it('should notify error when update is not successful', () => { + compAsAny.resourcePolicyService.update.and.returnValue(observableOf(createFailedRemoteDataObject({}))); + + scheduler = getTestScheduler(); + scheduler.schedule(() => comp.updateResourcePolicy(eventPayload)); + scheduler.flush(); + + expect(compAsAny.resourcePolicyService.update).toHaveBeenCalledWith(updatedObject); + expect(comp.redirectToAuthorizationsPage).not.toHaveBeenCalled(); + }); + }); + }); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/shared/resource-policies/edit/resource-policy-edit.component.ts b/src/app/shared/resource-policies/edit/resource-policy-edit.component.ts new file mode 100644 index 0000000000..e3927e7fcd --- /dev/null +++ b/src/app/shared/resource-policies/edit/resource-policy-edit.component.ts @@ -0,0 +1,102 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { BehaviorSubject, Observable } from 'rxjs'; +import { first, map, take } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; + +import { ResourcePolicyService } from '../../../core/resource-policy/resource-policy.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ResourcePolicy } from '../../../core/resource-policy/models/resource-policy.model'; +import { ResourcePolicyEvent } from '../form/resource-policy-form.component'; +import { ITEM_EDIT_AUTHORIZATIONS_PATH } from '../../../+item-page/edit-item-page/edit-item-page.routing.module'; +import { RESOURCE_POLICY } from '../../../core/resource-policy/models/resource-policy.resource-type'; + +@Component({ + selector: 'ds-resource-policy-edit', + templateUrl: './resource-policy-edit.component.html' +}) +export class ResourcePolicyEditComponent implements OnInit { + + /** + * The resource policy object to edit + */ + public resourcePolicy: ResourcePolicy; + + /** + * A boolean representing if a submission editing operation is pending + * @type {BehaviorSubject} + */ + private processing$ = new BehaviorSubject(false); + + /** + * Initialize instance variables + * + * @param {NotificationsService} notificationsService + * @param {ResourcePolicyService} resourcePolicyService + * @param {ActivatedRoute} route + * @param {Router} router + * @param {TranslateService} translate + */ + constructor( + private notificationsService: NotificationsService, + private resourcePolicyService: ResourcePolicyService, + private route: ActivatedRoute, + private router: Router, + private translate: TranslateService) { + } + + /** + * Initialize the component + */ + ngOnInit(): void { + this.route.data.pipe( + map((data) => data), + take(1) + ).subscribe((data: any) => { + this.resourcePolicy = (data.resourcePolicy as RemoteData).payload; + }); + } + + /** + * Return a boolean representing if an operation is pending + * + * @return {Observable} + */ + isProcessing(): Observable { + return this.processing$.asObservable(); + } + + /** + * Redirect to the authorizations page + */ + redirectToAuthorizationsPage() { + this.router.navigate([`../../${ITEM_EDIT_AUTHORIZATIONS_PATH}`], { relativeTo: this.route }); + } + + /** + * Update a resource policy + * + * @param event The {{ResourcePolicyEvent}} emitted + */ + updateResourcePolicy(event: ResourcePolicyEvent) { + this.processing$.next(true); + const updatedObject = Object.assign({}, event.object, { + id: this.resourcePolicy.id, + type: RESOURCE_POLICY.value, + _links: this.resourcePolicy._links + }); + this.resourcePolicyService.update(updatedObject).pipe( + first((response: RemoteData) => !response.isResponsePending) + ).subscribe((responseRD: RemoteData) => { + this.processing$.next(false); + if (responseRD.hasSucceeded) { + this.notificationsService.success(null, this.translate.get('resource-policies.edit.page.success.content')); + this.redirectToAuthorizationsPage(); + } else { + this.notificationsService.error(null, this.translate.get('resource-policies.edit.page.failure.content')); + } + }) + } +} diff --git a/src/app/shared/resource-policies/form/eperson-group-list/eperson-group-list.component.html b/src/app/shared/resource-policies/form/eperson-group-list/eperson-group-list.component.html new file mode 100644 index 0000000000..ce6eccb723 --- /dev/null +++ b/src/app/shared/resource-policies/form/eperson-group-list/eperson-group-list.component.html @@ -0,0 +1,37 @@ +
+ + + + + +
+ + + + + + + + + + + + + + + +
{{'resource-policies.form.eperson-group-list.table.headers.id' | translate}}{{'resource-policies.form.eperson-group-list.table.headers.name' | translate}}{{'resource-policies.form.eperson-group-list.table.headers.action' | translate}}
{{entry.id}}{{dsoNameService.getName(entry)}} + +
+
+ +
+
diff --git a/src/app/shared/resource-policies/form/eperson-group-list/eperson-group-list.component.scss b/src/app/shared/resource-policies/form/eperson-group-list/eperson-group-list.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/resource-policies/form/eperson-group-list/eperson-group-list.component.spec.ts b/src/app/shared/resource-policies/form/eperson-group-list/eperson-group-list.component.spec.ts new file mode 100644 index 0000000000..11d714a30e --- /dev/null +++ b/src/app/shared/resource-policies/form/eperson-group-list/eperson-group-list.component.spec.ts @@ -0,0 +1,287 @@ +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { ChangeDetectorRef, Component, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; + +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { cold } from 'jasmine-marbles'; +import { uniqueId } from 'lodash'; + +import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils'; +import { createTestComponent } from '../../../testing/utils.test'; +import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; +import { GroupDataService } from '../../../../core/eperson/group-data.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { getMockRequestService } from '../../../mocks/request.service.mock'; +import { EpersonGroupListComponent, SearchEvent } from './eperson-group-list.component'; +import { EPersonMock } from '../../../testing/eperson.mock'; +import { GroupMock } from '../../../testing/group-mock'; +import { PaginationComponentOptions } from '../../../pagination/pagination-component-options.model'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +describe('EpersonGroupListComponent test suite', () => { + let comp: EpersonGroupListComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let de; + let groupService: any; + let epersonService: any; + + const paginationOptions: PaginationComponentOptions = new PaginationComponentOptions() + paginationOptions.id = uniqueId('eperson-group-list-pagination-test'); + paginationOptions.pageSize = 5; + + const mockEpersonService = jasmine.createSpyObj('epersonService', + { + findByHref: jasmine.createSpy('findByHref'), + findAll: jasmine.createSpy('findAll'), + searchByScope: jasmine.createSpy('searchByScope'), + }, + { + linkPath: 'epersons' + } + ); + + const mockGroupService = jasmine.createSpyObj('groupService', + { + findByHref: jasmine.createSpy('findByHref'), + findAll: jasmine.createSpy('findAll'), + searchGroups: jasmine.createSpy('searchGroups'), + }, + { + linkPath: 'groups' + } + ); + + const epersonPaginatedList = new PaginatedList(new PageInfo(), [EPersonMock, EPersonMock]); + const epersonPaginatedListRD = createSuccessfulRemoteDataObject(epersonPaginatedList); + + const groupPaginatedList = new PaginatedList(new PageInfo(), [GroupMock, GroupMock]); + const groupPaginatedListRD = createSuccessfulRemoteDataObject(groupPaginatedList); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + TranslateModule.forRoot() + ], + declarations: [ + EpersonGroupListComponent, + TestComponent + ], + providers: [ + { provide: EPersonDataService, useValue: mockEpersonService }, + { provide: GroupDataService, useValue: mockGroupService }, + { provide: RequestService, useValue: getMockRequestService() }, + EpersonGroupListComponent, + ChangeDetectorRef, + Injector + ], + schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create EpersonGroupListComponent', inject([EpersonGroupListComponent], (app: EpersonGroupListComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('when is list of eperson', () => { + + beforeEach(() => { + // initTestScheduler(); + fixture = TestBed.createComponent(EpersonGroupListComponent); + epersonService = TestBed.get(EPersonDataService); + comp = fixture.componentInstance; + compAsAny = fixture.componentInstance; + comp.isListOfEPerson = true; + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + it('should inject EPersonDataService', () => { + spyOn(comp, 'updateList'); + fixture.detectChanges(); + + expect(compAsAny.dataService).toBeDefined(); + expect(comp.updateList).toHaveBeenCalled(); + }); + + it('should init entrySelectedId', () => { + spyOn(comp, 'updateList'); + comp.initSelected = EPersonMock.id; + + fixture.detectChanges(); + + expect(compAsAny.entrySelectedId.value).toBe(EPersonMock.id) + }); + + it('should init the list of eperson', () => { + epersonService.searchByScope.and.returnValue(observableOf(epersonPaginatedListRD)); + fixture.detectChanges(); + + expect(compAsAny.list$.value).toEqual(epersonPaginatedListRD); + expect(comp.getList()).toBeObservable(cold('a', { + a: epersonPaginatedListRD + })); + }); + + it('should emit select event', () => { + spyOn(comp.select, 'emit'); + comp.emitSelect(EPersonMock); + + expect(comp.select.emit).toHaveBeenCalled(); + expect(compAsAny.entrySelectedId.value).toBe(EPersonMock.id); + }); + + it('should return true when entry is selected', () => { + compAsAny.entrySelectedId.next(EPersonMock.id); + + expect(comp.isSelected(EPersonMock)).toBeObservable(cold('a', { + a: true + })); + }); + + it('should return false when entry is not selected', () => { + compAsAny.entrySelectedId.next(''); + + expect(comp.isSelected(EPersonMock)).toBeObservable(cold('a', { + a: false + })); + }); + + it('should update list on page change', () => { + spyOn(comp, 'updateList'); + comp.onPageChange(2); + + expect(compAsAny.updateList).toHaveBeenCalled(); + }); + }); + + describe('when is list of group', () => { + + beforeEach(() => { + // initTestScheduler(); + fixture = TestBed.createComponent(EpersonGroupListComponent); + groupService = TestBed.get(GroupDataService); + comp = fixture.componentInstance; + compAsAny = fixture.componentInstance; + comp.isListOfEPerson = false; + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + it('should inject GroupDataService', () => { + spyOn(comp, 'updateList'); + fixture.detectChanges(); + + expect(compAsAny.dataService).toBeDefined(); + expect(comp.updateList).toHaveBeenCalled(); + }); + + it('should init entrySelectedId', () => { + spyOn(comp, 'updateList'); + comp.initSelected = GroupMock.id; + + fixture.detectChanges(); + + expect(compAsAny.entrySelectedId.value).toBe(GroupMock.id) + }); + + it('should init the list of group', () => { + groupService.searchGroups.and.returnValue(observableOf(groupPaginatedListRD)); + fixture.detectChanges(); + + expect(compAsAny.list$.value).toEqual(groupPaginatedListRD); + expect(comp.getList()).toBeObservable(cold('a', { + a: groupPaginatedListRD + })); + }); + + it('should emit select event', () => { + spyOn(comp.select, 'emit'); + comp.emitSelect(GroupMock); + + expect(comp.select.emit).toHaveBeenCalled(); + expect(compAsAny.entrySelectedId.value).toBe(GroupMock.id); + }); + + it('should return true when entry is selected', () => { + compAsAny.entrySelectedId.next(EPersonMock.id); + + expect(comp.isSelected(EPersonMock)).toBeObservable(cold('a', { + a: true + })); + }); + + it('should return false when entry is not selected', () => { + compAsAny.entrySelectedId.next(''); + + expect(comp.isSelected(EPersonMock)).toBeObservable(cold('a', { + a: false + })); + }); + + it('should update list on page change', () => { + spyOn(comp, 'updateList'); + comp.onPageChange(2); + + expect(compAsAny.updateList).toHaveBeenCalled(); + }); + + it('should update list on search triggered', () => { + const options: PaginationComponentOptions = comp.paginationOptions + const event: SearchEvent = { + scope: 'metadata', + query: 'test' + } + spyOn(comp, 'updateList'); + comp.onSearch(event); + + expect(compAsAny.updateList).toHaveBeenCalledWith(options, 'metadata', 'test'); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + isListOfEPerson = true; + initSelected = ''; +} diff --git a/src/app/shared/resource-policies/form/eperson-group-list/eperson-group-list.component.ts b/src/app/shared/resource-policies/form/eperson-group-list/eperson-group-list.component.ts new file mode 100644 index 0000000000..02c4726d45 --- /dev/null +++ b/src/app/shared/resource-policies/form/eperson-group-list/eperson-group-list.component.ts @@ -0,0 +1,201 @@ +import { Component, EventEmitter, Injector, Input, OnDestroy, OnInit, Output } from '@angular/core'; + +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { uniqueId } from 'lodash' + +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { PaginationComponentOptions } from '../../../pagination/pagination-component-options.model'; +import { DataService } from '../../../../core/data/data.service'; +import { hasValue, isNotEmpty } from '../../../empty.util'; +import { FindListOptions } from '../../../../core/data/request.models'; +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { getDataServiceFor } from '../../../../core/cache/builders/build-decorators'; +import { EPERSON } from '../../../../core/eperson/models/eperson.resource-type'; +import { GROUP } from '../../../../core/eperson/models/group.resource-type'; +import { ResourceType } from '../../../../core/shared/resource-type'; +import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; +import { GroupDataService } from '../../../../core/eperson/group-data.service'; +import { fadeInOut } from '../../../animations/fade'; + +export interface SearchEvent { + scope: string; + query: string +} + +@Component({ + selector: 'ds-eperson-group-list', + styleUrls: ['./eperson-group-list.component.scss'], + templateUrl: './eperson-group-list.component.html', + animations: [ + fadeInOut + ] +}) +/** + * Component that shows a list of eperson or group + */ +export class EpersonGroupListComponent implements OnInit, OnDestroy { + + /** + * A boolean representing id component should list eperson or group + */ + @Input() isListOfEPerson = true; + + /** + * The uuid of eperson or group initially selected + */ + @Input() initSelected: string; + + /** + * An event fired when a eperson or group is selected. + * Event's payload equals to DSpaceObject. + */ + @Output() select: EventEmitter = new EventEmitter(); + + /** + * Current search query + */ + public currentSearchQuery = ''; + + /** + * Current search scope + */ + public currentSearchScope = 'metadata'; + + /** + * Pagination config used to display the list + */ + public paginationOptions: PaginationComponentOptions = new PaginationComponentOptions(); + + /** + * The data service used to make request. + * It could be EPersonDataService or GroupDataService + */ + private dataService: DataService; + + /** + * A list of eperson or group + */ + private list$: BehaviorSubject>> = new BehaviorSubject>>({} as any); + + /** + * The eperson or group's id selected + * @type {string} + */ + private entrySelectedId: BehaviorSubject = new BehaviorSubject(''); + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + /** + * Initialize instance variables and inject the properly DataService + * + * @param {DSONameService} dsoNameService + * @param {Injector} parentInjector + */ + constructor(public dsoNameService: DSONameService, private parentInjector: Injector) { + } + + /** + * Initialize the component + */ + ngOnInit(): void { + const resourceType: ResourceType = (this.isListOfEPerson) ? EPERSON : GROUP; + const provider = getDataServiceFor(resourceType); + this.dataService = Injector.create({ + providers: [], + parent: this.parentInjector + }).get(provider); + this.paginationOptions.id = uniqueId('eperson-group-list-pagination'); + this.paginationOptions.pageSize = 5; + + if (this.initSelected) { + this.entrySelectedId.next(this.initSelected); + } + + this.updateList(this.paginationOptions, this.currentSearchScope, this.currentSearchQuery); + } + + /** + * Method called when an entry is selected. + * Emit a new select Event + * + * @param entry The eperson or group selected + */ + emitSelect(entry: DSpaceObject): void { + this.select.emit(entry); + this.entrySelectedId.next(entry.id); + } + + /** + * Return the list of eperson or group + */ + getList(): Observable>> { + return this.list$.asObservable(); + } + + /** + * Return a boolean representing if a table row is selected + * + * @return {boolean} + */ + isSelected(entry: DSpaceObject): Observable { + return this.entrySelectedId.asObservable().pipe( + map((selectedId) => isNotEmpty(selectedId) && selectedId === entry.id) + ) + } + + /** + * Method called on page change + */ + onPageChange(page: number): void { + this.paginationOptions.currentPage = page; + this.updateList(this.paginationOptions, this.currentSearchScope, this.currentSearchQuery); + } + + /** + * Method called on search + */ + onSearch(searchEvent: SearchEvent) { + this.currentSearchQuery = searchEvent.query; + this.currentSearchScope = searchEvent.scope; + this.paginationOptions.currentPage = 1; + this.updateList(this.paginationOptions, this.currentSearchScope, this.currentSearchQuery); + } + + /** + * Retrieve a paginate list of eperson or group + */ + updateList(config: PaginationComponentOptions, scope: string, query: string): void { + const options: FindListOptions = Object.assign({}, new FindListOptions(), { + elementsPerPage: config.pageSize, + currentPage: config.currentPage + }); + + const search$: Observable>> = this.isListOfEPerson ? + (this.dataService as EPersonDataService).searchByScope(scope, query, options) : + (this.dataService as GroupDataService).searchGroups(query, options); + + this.subs.push(search$.pipe(take(1)) + .subscribe((list: RemoteData>) => { + this.list$.next(list) + }) + ); + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.list$ = null; + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()) + } + +} diff --git a/src/app/shared/resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component.html b/src/app/shared/resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component.html new file mode 100644 index 0000000000..0d130c723c --- /dev/null +++ b/src/app/shared/resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component.html @@ -0,0 +1,26 @@ +
+
+ +
+
+
+ + + + +
+
+
+ +
+
diff --git a/src/app/shared/resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component.spec.ts b/src/app/shared/resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component.spec.ts new file mode 100644 index 0000000000..8f04948f26 --- /dev/null +++ b/src/app/shared/resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component.spec.ts @@ -0,0 +1,115 @@ +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { TranslateModule } from '@ngx-translate/core'; + +import { createTestComponent } from '../../../../testing/utils.test'; +import { EpersonSearchBoxComponent } from './eperson-search-box.component'; +import { SearchEvent } from '../eperson-group-list.component'; + +describe('EpersonSearchBoxComponent test suite', () => { + let comp: EpersonSearchBoxComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let de; + let formBuilder: FormBuilder; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + TranslateModule.forRoot() + ], + declarations: [ + EpersonSearchBoxComponent, + TestComponent + ], + providers: [ + FormBuilder, + EpersonSearchBoxComponent + ], + schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create EpersonSearchBoxComponent', inject([EpersonSearchBoxComponent], (app: EpersonSearchBoxComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + // initTestScheduler(); + fixture = TestBed.createComponent(EpersonSearchBoxComponent); + formBuilder = TestBed.get(FormBuilder); + comp = fixture.componentInstance; + compAsAny = fixture.componentInstance; + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + it('should reset the form', () => { + comp.searchForm = formBuilder.group(({ + query: 'test', + })); + + comp.reset(); + + expect(comp.searchForm.controls.query.value).toBe(''); + }); + + it('should emit new search event', () => { + const data = { + scope: 'metadata', + query: 'test' + } + + const event: SearchEvent = { + scope: 'metadata', + query: 'test' + } + spyOn(comp.search, 'emit'); + + comp.submit(data); + + expect(comp.search.emit).toHaveBeenCalledWith(event); + }); + }) +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/shared/resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component.ts b/src/app/shared/resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component.ts new file mode 100644 index 0000000000..04c5cafd3f --- /dev/null +++ b/src/app/shared/resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component.ts @@ -0,0 +1,65 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; + +import { Subscription } from 'rxjs'; + +import { SearchEvent } from '../eperson-group-list.component'; +import { isNotNull } from '../../../../empty.util'; + +/** + * A component used to show a search box for epersons. + */ +@Component({ + selector: 'ds-eperson-search-box', + templateUrl: './eperson-search-box.component.html', +}) +export class EpersonSearchBoxComponent { + + labelPrefix = 'admin.access-control.epeople.'; + + /** + * The search form + */ + searchForm; + + /** + * List of subscriptions + */ + subs: Subscription[] = []; + + /** + * An event fired when a search is triggred. + * Event's payload is a SearchEvent. + */ + @Output() search: EventEmitter = new EventEmitter(); + + constructor(private formBuilder: FormBuilder) { + this.searchForm = this.formBuilder.group(({ + scope: 'metadata', + query: '', + })); + } + + /** + * Reset the search form + */ + reset() { + this.searchForm = this.formBuilder.group(({ + scope: 'metadata', + query: '', + })); + } + + /** + * Emit a new search event + * @param data Form data + */ + submit(data: any) { + const event: SearchEvent = { + scope: isNotNull(data) ? data.scope : 'metadata', + query: isNotNull(data) ? data.query : '' + } + + this.search.emit(event) + } +} diff --git a/src/app/shared/resource-policies/form/eperson-group-list/group-search-box/group-search-box.component.html b/src/app/shared/resource-policies/form/eperson-group-list/group-search-box/group-search-box.component.html new file mode 100644 index 0000000000..418996c564 --- /dev/null +++ b/src/app/shared/resource-policies/form/eperson-group-list/group-search-box/group-search-box.component.html @@ -0,0 +1,20 @@ +
+
+
+ + + + +
+
+
+ +
+
diff --git a/src/app/shared/resource-policies/form/eperson-group-list/group-search-box/group-search-box.component.spec.ts b/src/app/shared/resource-policies/form/eperson-group-list/group-search-box/group-search-box.component.spec.ts new file mode 100644 index 0000000000..bcc71a63de --- /dev/null +++ b/src/app/shared/resource-policies/form/eperson-group-list/group-search-box/group-search-box.component.spec.ts @@ -0,0 +1,114 @@ +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { TranslateModule } from '@ngx-translate/core'; + +import { createTestComponent } from '../../../../testing/utils.test'; +import { GroupSearchBoxComponent } from './group-search-box.component'; +import { SearchEvent } from '../eperson-group-list.component'; + +describe('GroupSearchBoxComponent test suite', () => { + let comp: GroupSearchBoxComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let de; + let formBuilder: FormBuilder; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + TranslateModule.forRoot() + ], + declarations: [ + GroupSearchBoxComponent, + TestComponent + ], + providers: [ + FormBuilder, + GroupSearchBoxComponent + ], + schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create GroupSearchBoxComponent', inject([GroupSearchBoxComponent], (app: GroupSearchBoxComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + // initTestScheduler(); + fixture = TestBed.createComponent(GroupSearchBoxComponent); + formBuilder = TestBed.get(FormBuilder); + comp = fixture.componentInstance; + compAsAny = fixture.componentInstance; + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + it('should reset the form', () => { + comp.searchForm = formBuilder.group(({ + query: 'test', + })); + + comp.reset(); + + expect(comp.searchForm.controls.query.value).toBe(''); + }); + + it('should emit new search event', () => { + const data = { + query: 'test' + } + + const event: SearchEvent = { + scope: '', + query: 'test' + } + spyOn(comp.search, 'emit'); + + comp.submit(data); + + expect(comp.search.emit).toHaveBeenCalledWith(event); + }); + }) +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/shared/resource-policies/form/eperson-group-list/group-search-box/group-search-box.component.ts b/src/app/shared/resource-policies/form/eperson-group-list/group-search-box/group-search-box.component.ts new file mode 100644 index 0000000000..5f4feb582f --- /dev/null +++ b/src/app/shared/resource-policies/form/eperson-group-list/group-search-box/group-search-box.component.ts @@ -0,0 +1,61 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; + +import { Subscription } from 'rxjs'; + +import { SearchEvent } from '../eperson-group-list.component'; + +/** + * A component used to show a search box for groups. + */ +@Component({ + selector: 'ds-group-search-box', + templateUrl: './group-search-box.component.html', +}) +export class GroupSearchBoxComponent { + + labelPrefix = 'admin.access-control.groups.'; + + /** + * The search form + */ + searchForm; + + /** + * List of subscriptions + */ + subs: Subscription[] = []; + + /** + * An event fired when a search is triggred. + * Event's payload is a SearchEvent. + */ + @Output() search: EventEmitter = new EventEmitter(); + + constructor(private formBuilder: FormBuilder) { + this.searchForm = this.formBuilder.group(({ + query: '', + })); + } + + /** + * Reset the search form + */ + reset() { + this.searchForm = this.formBuilder.group(({ + query: '', + })); + } + + /** + * Emit a new search event + * @param data Form data + */ + submit(data: any) { + const event: SearchEvent = { + scope: '', + query: data.query + } + this.search.emit(event) + } +} diff --git a/src/app/shared/resource-policies/form/resource-policy-form.component.html b/src/app/shared/resource-policies/form/resource-policy-form.component.html new file mode 100644 index 0000000000..6585755145 --- /dev/null +++ b/src/app/shared/resource-policies/form/resource-policy-form.component.html @@ -0,0 +1,47 @@ +
+ +
+ + + + + + + + + + + + + + +
+
+
+ +
+ + +
+
+
+
+
diff --git a/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts b/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts new file mode 100644 index 0000000000..03978212d7 --- /dev/null +++ b/src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts @@ -0,0 +1,427 @@ +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { BrowserModule, By } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { getTestScheduler } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { delay } from 'rxjs/operators'; +import { TranslateModule } from '@ngx-translate/core'; + +import { createSuccessfulRemoteDataObject } from '../../remote-data.utils'; +import { createTestComponent } from '../../testing/utils.test'; +import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; +import { GroupDataService } from '../../../core/eperson/group-data.service'; +import { RequestService } from '../../../core/data/request.service'; +import { getMockRequestService } from '../../mocks/request.service.mock'; +import { PolicyType } from '../../../core/resource-policy/models/policy-type.model'; +import { ActionType } from '../../../core/resource-policy/models/action-type.model'; +import { GroupMock } from '../../testing/group-mock'; +import { ResourcePolicyEvent, ResourcePolicyFormComponent } from './resource-policy-form.component'; +import { FormService } from '../../form/form.service'; +import { getMockFormService } from '../../mocks/form-service.mock'; +import { FormBuilderService } from '../../form/builder/form-builder.service'; +import { EpersonGroupListComponent } from './eperson-group-list/eperson-group-list.component'; +import { FormComponent } from '../../form/form.component'; +import { stringToNgbDateStruct } from '../../date.util'; +import { ResourcePolicy } from '../../../core/resource-policy/models/resource-policy.model'; +import { RESOURCE_POLICY } from '../../../core/resource-policy/models/resource-policy.resource-type'; +import { EPersonMock } from '../../testing/eperson.mock'; + +export const mockResourcePolicyFormData = { + name: [ + { + value: 'name', + language: null, + authority: null, + display: 'name', + confidence: -1, + place: 0, + otherInformation: null + } + ], + description: [ + { + value: 'description', + language: null, + authority: null, + display: 'description', + confidence: -1, + place: 0, + otherInformation: null + } + ], + policyType: [ + { + value: 'TYPE_WORKFLOW', + language: null, + authority: null, + display: 'TYPE_WORKFLOW', + confidence: -1, + place: 0, + otherInformation: null + } + ], + action: [ + { + value: 'WRITE', + language: null, + authority: null, + display: 'WRITE', + confidence: -1, + place: 0, + otherInformation: null + } + ], + date: { + start: [ + { + value: { year: '2019', month: '04', day: '14' }, + language: null, + authority: null, + display: '2019-04-14', + confidence: -1, + place: 0, + otherInformation: null + } + ], + end: [ + { + value: { year: '2020', month: '04', day: '14' }, + language: null, + authority: null, + display: '2020-04-14', + confidence: -1, + place: 0, + otherInformation: null + } + ], + } +}; + +export const submittedResourcePolicy = Object.assign(new ResourcePolicy(), { + name: 'name', + description: 'description', + policyType: PolicyType.TYPE_WORKFLOW, + action: ActionType.WRITE, + startDate: '2019-04-14T00:00:00Z', + endDate: '2020-04-14T00:00:00Z', + type: RESOURCE_POLICY +}); + +describe('ResourcePolicyFormComponent test suite', () => { + let comp: ResourcePolicyFormComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let de; + let scheduler: TestScheduler; + + const resourcePolicy: any = { + id: '1', + name: null, + description: null, + policyType: PolicyType.TYPE_SUBMISSION, + action: ActionType.READ, + startDate: '2019-04-14', + endDate: '2020-04-14', + type: 'resourcepolicy', + uuid: 'resource-policy-1', + _links: { + eperson: { + href: 'https://rest.api/rest/api/eperson' + }, + group: { + href: 'https://rest.api/rest/api/group' + }, + self: { + href: 'https://rest.api/rest/api/resourcepolicies/1' + }, + }, + eperson: observableOf(createSuccessfulRemoteDataObject({})), + group: observableOf(createSuccessfulRemoteDataObject(GroupMock)) + }; + + const epersonService = jasmine.createSpyObj('epersonService', { + findByHref: jasmine.createSpy('findByHref'), + findAll: jasmine.createSpy('findAll') + }); + + const groupService = jasmine.createSpyObj('groupService', { + findByHref: jasmine.createSpy('findByHref'), + findAll: jasmine.createSpy('findAll') + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + CommonModule, + FormsModule, + ReactiveFormsModule, + TranslateModule.forRoot() + ], + declarations: [ + FormComponent, + EpersonGroupListComponent, + ResourcePolicyFormComponent, + TestComponent + ], + providers: [ + { provide: EPersonDataService, useValue: epersonService }, + { provide: FormService, useValue: getMockFormService() }, + { provide: GroupDataService, useValue: groupService }, + { provide: RequestService, useValue: getMockRequestService() }, + FormBuilderService, + ChangeDetectorRef, + ResourcePolicyFormComponent + ], + schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create ResourcePolicyFormComponent', inject([ResourcePolicyFormComponent], (app: ResourcePolicyFormComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('when resource policy is not provided', () => { + + beforeEach(() => { + // initTestScheduler(); + fixture = TestBed.createComponent(ResourcePolicyFormComponent); + comp = fixture.componentInstance; + compAsAny = fixture.componentInstance; + comp.isProcessing = observableOf(false); + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + it('should init form model properly', () => { + spyOn(compAsAny, 'isFormValid').and.returnValue(observableOf(false)); + spyOn(compAsAny, 'initModelsValue').and.callThrough(); + spyOn(compAsAny, 'buildResourcePolicyForm').and.callThrough(); + fixture.detectChanges(); + + expect(compAsAny.buildResourcePolicyForm).toHaveBeenCalled(); + expect(compAsAny.initModelsValue).toHaveBeenCalled(); + expect(compAsAny.formModel.length).toBe(5); + expect(compAsAny.subs.length).toBe(0); + + }); + + it('should can set grant', () => { + expect(comp.canSetGrant()).toBeTruthy(); + }); + + it('should not have a target name', () => { + expect(comp.getResourcePolicyTargetName()).toBe(''); + }); + + it('should emit reset event', () => { + spyOn(compAsAny.reset, 'emit'); + comp.onReset(); + expect(compAsAny.reset.emit).toHaveBeenCalled(); + }); + + it('should update resource policy grant object properly', () => { + comp.updateObjectSelected(EPersonMock, true); + + expect(comp.resourcePolicyGrant).toEqual(EPersonMock); + expect(comp.resourcePolicyGrantType).toBe('eperson'); + + comp.updateObjectSelected(GroupMock, false); + + expect(comp.resourcePolicyGrant).toEqual(GroupMock); + expect(comp.resourcePolicyGrantType).toBe('group'); + }); + + }); + + describe('when resource policy is provided', () => { + + beforeEach(() => { + // initTestScheduler(); + fixture = TestBed.createComponent(ResourcePolicyFormComponent); + comp = fixture.componentInstance; + compAsAny = fixture.componentInstance; + comp.resourcePolicy = resourcePolicy; + comp.isProcessing = observableOf(false); + compAsAny.ePersonService.findByHref.and.returnValue( + observableOf(createSuccessfulRemoteDataObject({})).pipe(delay(100)) + ); + compAsAny.groupService.findByHref.and.returnValue(observableOf(createSuccessfulRemoteDataObject(GroupMock))); + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + it('should init form model properly', () => { + spyOn(compAsAny, 'isFormValid').and.returnValue(observableOf(false)); + spyOn(compAsAny, 'initModelsValue').and.callThrough(); + spyOn(compAsAny, 'buildResourcePolicyForm').and.callThrough(); + fixture.detectChanges(); + + expect(compAsAny.buildResourcePolicyForm).toHaveBeenCalled(); + expect(compAsAny.initModelsValue).toHaveBeenCalled(); + expect(compAsAny.formModel.length).toBe(5); + expect(compAsAny.subs.length).toBe(1); + expect(compAsAny.formModel[2].value).toBe('TYPE_SUBMISSION'); + expect(compAsAny.formModel[3].value).toBe('READ'); + expect(compAsAny.formModel[4].get(0).value).toEqual(stringToNgbDateStruct('2019-04-14')); + expect(compAsAny.formModel[4].get(1).value).toEqual(stringToNgbDateStruct('2020-04-14')); + + }); + + it('should init resourcePolicyGrant properly', () => { + compAsAny.isActive = true; + + scheduler = getTestScheduler(); + scheduler.schedule(() => comp.ngOnInit()); + scheduler.flush(); + + expect(compAsAny.resourcePolicyGrant).toEqual(GroupMock); + }); + + it('should not can set grant', () => { + expect(comp.canSetGrant()).toBeFalsy(); + }); + + it('should have a target name', () => { + compAsAny.resourcePolicyGrant = GroupMock; + + expect(comp.getResourcePolicyTargetName()).toBe('testgroupname'); + }); + + }); + + describe('when form is valid', () => { + beforeEach(() => { + + fixture = TestBed.createComponent(ResourcePolicyFormComponent); + comp = fixture.componentInstance; + compAsAny = comp; + comp.resourcePolicy = resourcePolicy; + comp.isProcessing = observableOf(false); + compAsAny.ePersonService.findByHref.and.returnValue( + observableOf(createSuccessfulRemoteDataObject({})).pipe(delay(100)) + ); + compAsAny.groupService.findByHref.and.returnValue(observableOf(createSuccessfulRemoteDataObject(GroupMock))); + compAsAny.formService.isValid.and.returnValue(observableOf(true)); + compAsAny.isActive = true; + comp.resourcePolicyGrant = GroupMock; + comp.resourcePolicyGrantType = 'group'; + fixture.detectChanges(); + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + it('should not have submit button disabled when submission is valid', () => { + + const depositBtn: any = fixture.debugElement.query(By.css('.btn-primary')); + + expect(depositBtn.nativeElement.disabled).toBeFalsy(); + }); + + it('should emit submit event', () => { + spyOn(compAsAny.submit, 'emit'); + spyOn(compAsAny, 'createResourcePolicyByFormData').and.callThrough(); + compAsAny.formService.getFormData.and.returnValue(observableOf(mockResourcePolicyFormData)); + const eventPayload: ResourcePolicyEvent = Object.create({}); + eventPayload.object = submittedResourcePolicy; + eventPayload.target = { + type: 'group', + uuid: GroupMock.id + }; + + scheduler = getTestScheduler(); + scheduler.schedule(() => comp.onSubmit()); + + scheduler.flush(); + + expect(compAsAny.submit.emit).toHaveBeenCalledWith(eventPayload); + expect(compAsAny.createResourcePolicyByFormData).toHaveBeenCalled(); + }); + + }); + + describe('when form is not valid', () => { + beforeEach(() => { + + fixture = TestBed.createComponent(ResourcePolicyFormComponent); + comp = fixture.componentInstance; + compAsAny = comp; + comp.resourcePolicy = resourcePolicy; + comp.isProcessing = observableOf(false); + compAsAny.ePersonService.findByHref.and.returnValue( + observableOf(createSuccessfulRemoteDataObject({})).pipe(delay(100)) + ); + compAsAny.groupService.findByHref.and.returnValue(observableOf(createSuccessfulRemoteDataObject(GroupMock))); + compAsAny.formService.isValid.and.returnValue(observableOf(false)); + compAsAny.isActive = true; + fixture.detectChanges(); + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + it('should have submit button disabled when submission is valid', () => { + + const depositBtn: any = fixture.debugElement.query(By.css('.btn-primary')); + + expect(depositBtn.nativeElement.disabled).toBeTruthy(); + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + resourcePolicy = null; + isProcessing = observableOf(false); +} diff --git a/src/app/shared/resource-policies/form/resource-policy-form.component.ts b/src/app/shared/resource-policies/form/resource-policy-form.component.ts new file mode 100644 index 0000000000..803db655d3 --- /dev/null +++ b/src/app/shared/resource-policies/form/resource-policy-form.component.ts @@ -0,0 +1,317 @@ +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; + +import { Observable, of as observableOf, race as observableRace } from 'rxjs'; +import { filter, map, take } from 'rxjs/operators'; +import { + DynamicDatePickerModel, + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicSelectModel +} from '@ng-dynamic-forms/core'; + +import { ResourcePolicy } from '../../../core/resource-policy/models/resource-policy.model'; +import { DsDynamicInputModel } from '../../form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; +import { + RESOURCE_POLICY_FORM_ACTION_TYPE_CONFIG, + RESOURCE_POLICY_FORM_DATE_GROUP_CONFIG, + RESOURCE_POLICY_FORM_DATE_GROUP_LAYOUT, + RESOURCE_POLICY_FORM_DESCRIPTION_CONFIG, + RESOURCE_POLICY_FORM_END_DATE_CONFIG, + RESOURCE_POLICY_FORM_END_DATE_LAYOUT, + RESOURCE_POLICY_FORM_NAME_CONFIG, + RESOURCE_POLICY_FORM_POLICY_TYPE_CONFIG, + RESOURCE_POLICY_FORM_START_DATE_CONFIG, + RESOURCE_POLICY_FORM_START_DATE_LAYOUT +} from './resource-policy-form.model'; +import { DsDynamicTextAreaModel } from '../../form/builder/ds-dynamic-form-ui/models/ds-dynamic-textarea.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { hasValue, isEmpty, isNotEmpty } from '../../empty.util'; +import { FormService } from '../../form/form.service'; +import { RESOURCE_POLICY } from '../../../core/resource-policy/models/resource-policy.resource-type'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { dateToISOFormat, stringToNgbDateStruct } from '../../date.util'; +import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; +import { GroupDataService } from '../../../core/eperson/group-data.service'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { RequestService } from '../../../core/data/request.service'; + +export interface ResourcePolicyEvent { + object: ResourcePolicy, + target: { + type: string, + uuid: string + } +} + +@Component({ + selector: 'ds-resource-policy-form', + templateUrl: './resource-policy-form.component.html', +}) +/** + * Component that show form for adding/editing a resource policy + */ +export class ResourcePolicyFormComponent implements OnInit, OnDestroy { + + /** + * If given contains the resource policy to edit + * @type {ResourcePolicy} + */ + @Input() resourcePolicy: ResourcePolicy; + + /** + * A boolean representing if form submit operation is processing + * @type {boolean} + */ + @Input() isProcessing: Observable = observableOf(false); + + /** + * An event fired when form is canceled. + * Event's payload is empty. + */ + @Output() reset: EventEmitter = new EventEmitter(); + + /** + * An event fired when form is submitted. + * Event's payload equals to a new ResourcePolicy. + */ + @Output() submit: EventEmitter = new EventEmitter(); + + /** + * The form id + * @type {string} + */ + public formId: string; + + /** + * The form model + * @type {DynamicFormControlModel[]} + */ + public formModel: DynamicFormControlModel[]; + + /** + * The eperson or group that will be grant of the permission + * @type {DSpaceObject} + */ + public resourcePolicyGrant: DSpaceObject; + + /** + * The type of the object that will be grant of the permission. It could be 'eperson' or 'group' + * @type {string} + */ + public resourcePolicyGrantType: string; + + /** + * A boolean representing if component is active + * @type {boolean} + */ + private isActive: boolean; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {DSONameService} dsoNameService + * @param {EPersonDataService} ePersonService + * @param {FormService} formService + * @param {GroupDataService} groupService + * @param {RequestService} requestService + */ + constructor( + private dsoNameService: DSONameService, + private ePersonService: EPersonDataService, + private formService: FormService, + private groupService: GroupDataService, + private requestService: RequestService, + ) { + } + + /** + * Initialize the component, setting up the form model + */ + ngOnInit(): void { + this.isActive = true; + this.formId = this.formService.getUniqueId('resource-policy-form'); + this.formModel = this.buildResourcePolicyForm(); + + if (!this.canSetGrant()) { + this.requestService.removeByHrefSubstring(this.resourcePolicy._links.eperson.href); + this.requestService.removeByHrefSubstring(this.resourcePolicy._links.group.href); + const epersonRD$ = this.ePersonService.findByHref(this.resourcePolicy._links.eperson.href).pipe( + getSucceededRemoteData() + ); + const groupRD$ = this.groupService.findByHref(this.resourcePolicy._links.group.href).pipe( + getSucceededRemoteData() + ); + const dsoRD$: Observable> = observableRace(epersonRD$, groupRD$); + this.subs.push( + dsoRD$.pipe( + filter(() => this.isActive), + ).subscribe((dsoRD: RemoteData) => { + this.resourcePolicyGrant = dsoRD.payload; + }) + ) + } + } + + /** + * Method to check if the form status is valid or not + * + * @return Observable that emits the form status + */ + isFormValid(): Observable { + return this.formService.isValid(this.formId).pipe( + map((isValid: boolean) => isValid && isNotEmpty(this.resourcePolicyGrant)) + ) + } + + /** + * Initialize the form model + * + * @return the form models + */ + private buildResourcePolicyForm(): DynamicFormControlModel[] { + const formModel: DynamicFormControlModel[] = []; + // TODO to be removed when https://jira.lyrasis.org/browse/DS-4477 will be implemented + const policyTypeConf = Object.assign({}, RESOURCE_POLICY_FORM_POLICY_TYPE_CONFIG, { + disabled: isNotEmpty(this.resourcePolicy) + }); + // TODO to be removed when https://jira.lyrasis.org/browse/DS-4477 will be implemented + const actionConf = Object.assign({}, RESOURCE_POLICY_FORM_ACTION_TYPE_CONFIG, { + disabled: isNotEmpty(this.resourcePolicy) + }); + formModel.push( + new DsDynamicInputModel(RESOURCE_POLICY_FORM_NAME_CONFIG), + new DsDynamicTextAreaModel(RESOURCE_POLICY_FORM_DESCRIPTION_CONFIG), + new DynamicSelectModel(policyTypeConf), + new DynamicSelectModel(actionConf) + ); + + const startDateModel = new DynamicDatePickerModel( + RESOURCE_POLICY_FORM_START_DATE_CONFIG, + RESOURCE_POLICY_FORM_START_DATE_LAYOUT + ); + const endDateModel = new DynamicDatePickerModel( + RESOURCE_POLICY_FORM_END_DATE_CONFIG, + RESOURCE_POLICY_FORM_END_DATE_LAYOUT + ); + const dateGroupConfig = Object.assign({}, RESOURCE_POLICY_FORM_DATE_GROUP_CONFIG, { group: [] }); + dateGroupConfig.group.push(startDateModel, endDateModel); + formModel.push(new DynamicFormGroupModel(dateGroupConfig, RESOURCE_POLICY_FORM_DATE_GROUP_LAYOUT)); + + this.initModelsValue(formModel); + return formModel + } + + /** + * Setting up the form models value + * + * @return the form models + */ + initModelsValue(formModel: DynamicFormControlModel[]): DynamicFormControlModel[] { + if (this.resourcePolicy) { + formModel.forEach((model: any) => { + if (model.id === 'date') { + if (hasValue(this.resourcePolicy.startDate)) { + model.get(0).valueUpdates.next(stringToNgbDateStruct(this.resourcePolicy.startDate)); + } + if (hasValue(this.resourcePolicy.endDate)) { + model.get(1).valueUpdates.next(stringToNgbDateStruct(this.resourcePolicy.endDate)); + } + } else { + if (this.resourcePolicy.hasOwnProperty(model.id) && this.resourcePolicy[model.id]) { + model.valueUpdates.next(this.resourcePolicy[model.id]); + } + } + }) + } + + return formModel; + } + + /** + * Return a boolean representing If is possible to set policy grant + * + * @return true if is possible, false otherwise + */ + canSetGrant(): boolean { + return isEmpty(this.resourcePolicy); + } + + /** + * Return the name of the eperson or group that will be grant of the permission + * + * @return the object name + */ + getResourcePolicyTargetName(): string { + return isNotEmpty(this.resourcePolicyGrant) ? this.dsoNameService.getName(this.resourcePolicyGrant) : ''; + } + + /** + * Update reference to the eperson or group that will be grant of the permission + */ + updateObjectSelected(object: DSpaceObject, isEPerson: boolean): void { + this.resourcePolicyGrant = object; + this.resourcePolicyGrantType = isEPerson ? 'eperson' : 'group'; + } + + /** + * Method called on reset + * Emit a new reset Event + */ + onReset(): void { + this.reset.emit(); + } + + /** + * Method called on submit. + * Emit a new submit Event whether the form is valid + */ + onSubmit(): void { + this.formService.getFormData(this.formId).pipe(take(1)) + .subscribe((data) => { + const eventPayload: ResourcePolicyEvent = Object.create({}); + eventPayload.object = this.createResourcePolicyByFormData(data); + eventPayload.target = { + type: this.resourcePolicyGrantType, + uuid: this.resourcePolicyGrant.id + }; + this.submit.emit(eventPayload); + }) + } + + /** + * Create e new ResourcePolicy by form data + * + * @return the new ResourcePolicy object + */ + createResourcePolicyByFormData(data): ResourcePolicy { + const resourcePolicy = new ResourcePolicy(); + resourcePolicy.name = (data.name) ? data.name[0].value : null; + resourcePolicy.description = (data.description) ? data.description[0].value : null; + resourcePolicy.policyType = (data.policyType) ? data.policyType[0].value : null; + resourcePolicy.action = (data.action) ? data.action[0].value : null; + resourcePolicy.startDate = (data.date && data.date.start) ? dateToISOFormat(data.date.start[0].value) : null; + resourcePolicy.endDate = (data.date && data.date.end) ? dateToISOFormat(data.date.end[0].value) : null; + resourcePolicy.type = RESOURCE_POLICY; + + return resourcePolicy; + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.isActive = false; + this.formModel = null; + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()) + } +} diff --git a/src/app/shared/resource-policies/form/resource-policy-form.model.ts b/src/app/shared/resource-policies/form/resource-policy-form.model.ts new file mode 100644 index 0000000000..37f9b866a9 --- /dev/null +++ b/src/app/shared/resource-policies/form/resource-policy-form.model.ts @@ -0,0 +1,154 @@ +import { + DynamicDatePickerModelConfig, + DynamicFormControlLayout, + DynamicFormGroupModelConfig, + DynamicFormOptionConfig, + DynamicSelectModelConfig, +} from '@ng-dynamic-forms/core'; + +import { DsDynamicInputModelConfig } from '../../form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; +import { DsDynamicTextAreaModelConfig } from '../../form/builder/ds-dynamic-form-ui/models/ds-dynamic-textarea.model'; +import { PolicyType } from '../../../core/resource-policy/models/policy-type.model'; +import { ActionType } from '../../../core/resource-policy/models/action-type.model'; + +const policyTypeList: Array> = [ + { + label: PolicyType.TYPE_SUBMISSION, + value: PolicyType.TYPE_SUBMISSION + }, + { + label: PolicyType.TYPE_WORKFLOW, + value: PolicyType.TYPE_WORKFLOW + }, + { + label: PolicyType.TYPE_INHERITED, + value: PolicyType.TYPE_INHERITED + }, + { + label: PolicyType.TYPE_CUSTOM, + value: PolicyType.TYPE_CUSTOM + }, +]; + +const policyActionList: Array> = [ + { + label: ActionType.READ.toString(), + value: ActionType.READ + }, + { + label: ActionType.WRITE.toString(), + value: ActionType.WRITE + }, + { + label: ActionType.REMOVE.toString(), + value: ActionType.REMOVE + }, + { + label: ActionType.ADMIN.toString(), + value: ActionType.ADMIN + }, + { + label: ActionType.DELETE.toString(), + value: ActionType.DELETE + }, + { + label: ActionType.WITHDRAWN_READ.toString(), + value: ActionType.WITHDRAWN_READ + }, + { + label: ActionType.DEFAULT_BITSTREAM_READ.toString(), + value: ActionType.DEFAULT_BITSTREAM_READ + }, + { + label: ActionType.DEFAULT_ITEM_READ.toString(), + value: ActionType.DEFAULT_ITEM_READ + } +]; + +export const RESOURCE_POLICY_FORM_NAME_CONFIG: DsDynamicInputModelConfig = { + id: 'name', + label: 'resource-policies.form.name.label', + metadataFields: [], + repeatable: false, + submissionId: '' +}; + +export const RESOURCE_POLICY_FORM_DESCRIPTION_CONFIG: DsDynamicTextAreaModelConfig = { + id: 'description', + label: 'resource-policies.form.description.label', + metadataFields: [], + repeatable: false, + rows: 10, + submissionId: '' +}; + +export const RESOURCE_POLICY_FORM_POLICY_TYPE_CONFIG: DynamicSelectModelConfig = { + id: 'policyType', + label: 'resource-policies.form.policy-type.label', + options: policyTypeList, + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'resource-policies.form.policy-type.required' + } +}; + +export const RESOURCE_POLICY_FORM_ACTION_TYPE_CONFIG: DynamicSelectModelConfig = { + id: 'action', + label: 'resource-policies.form.action-type.label', + options: policyActionList, + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'resource-policies.form.action-type.required' + } +}; + +export const RESOURCE_POLICY_FORM_DATE_GROUP_CONFIG: DynamicFormGroupModelConfig = { + id: 'date', + group: [] +}; +export const RESOURCE_POLICY_FORM_DATE_GROUP_LAYOUT: DynamicFormControlLayout = { + element: { + control: 'form-row', + } +}; + +export const RESOURCE_POLICY_FORM_START_DATE_CONFIG: DynamicDatePickerModelConfig = { + id: 'start', + label: 'resource-policies.form.date.start.label', + placeholder: 'resource-policies.form.date.start.label', + inline: false, + toggleIcon: 'far fa-calendar-alt' +}; + +export const RESOURCE_POLICY_FORM_START_DATE_LAYOUT: DynamicFormControlLayout = { + element: { + container: 'p-0', + label: 'col-form-label' + }, + grid: { + host: 'col-md-6' + } +}; + +export const RESOURCE_POLICY_FORM_END_DATE_CONFIG: DynamicDatePickerModelConfig = { + id: 'end', + label: 'resource-policies.form.date.end.label', + placeholder: 'resource-policies.form.date.end.label', + inline: false, + toggleIcon: 'far fa-calendar-alt' +}; +export const RESOURCE_POLICY_FORM_END_DATE_LAYOUT: DynamicFormControlLayout = { + element: { + container: 'p-0', + label: 'col-form-label' + }, + grid: { + host: 'col-md-6' + } +}; diff --git a/src/app/shared/resource-policies/resolvers/resource-policy-target.resolver.ts b/src/app/shared/resource-policies/resolvers/resource-policy-target.resolver.ts new file mode 100644 index 0000000000..3580e86080 --- /dev/null +++ b/src/app/shared/resource-policies/resolvers/resource-policy-target.resolver.ts @@ -0,0 +1,53 @@ +import { Injectable, Injector } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; + +import { Observable } from 'rxjs'; +import { find } from 'rxjs/operators'; + +import { getDataServiceFor } from '../../../core/cache/builders/build-decorators'; +import { ResourceType } from '../../../core/shared/resource-type'; +import { DataService } from '../../../core/data/data.service'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { hasValue, isEmpty } from '../../empty.util'; +import { RemoteData } from '../../../core/data/remote-data'; + +/** + * This class represents a resolver that requests a specific item before the route is activated + */ +@Injectable() +export class ResourcePolicyTargetResolver implements Resolve> { + + /** + * The data service used to make request. + */ + private dataService: DataService; + + constructor(private parentInjector: Injector, private router: Router) { + } + + /** + * Method for resolving an item based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found item based on the parameters in the current route, + * or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + const targetType = route.queryParamMap.get('targetType'); + const policyTargetId = route.queryParamMap.get('policyTargetId'); + + if (isEmpty(targetType) || isEmpty(policyTargetId)) { + this.router.navigateByUrl('/404', { skipLocationChange: true }); + } + + const provider = getDataServiceFor(new ResourceType(targetType)); + this.dataService = Injector.create({ + providers: [], + parent: this.parentInjector + }).get(provider); + + return this.dataService.findById(policyTargetId).pipe( + find((RD) => hasValue(RD.error) || RD.hasSucceeded), + ); + } +} diff --git a/src/app/shared/resource-policies/resolvers/resource-policy.resolver.ts b/src/app/shared/resource-policies/resolvers/resource-policy.resolver.ts new file mode 100644 index 0000000000..2e38aca5b6 --- /dev/null +++ b/src/app/shared/resource-policies/resolvers/resource-policy.resolver.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; + +import { Observable } from 'rxjs'; +import { find } from 'rxjs/operators'; + +import { hasValue, isEmpty } from '../../empty.util'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ResourcePolicy } from '../../../core/resource-policy/models/resource-policy.model'; +import { ResourcePolicyService } from '../../../core/resource-policy/resource-policy.service'; +import { followLink } from '../../utils/follow-link-config.model'; + +/** + * This class represents a resolver that requests a specific item before the route is activated + */ +@Injectable() +export class ResourcePolicyResolver implements Resolve> { + + constructor(private resourcePolicyService: ResourcePolicyService, private router: Router) { + } + + /** + * Method for resolving an item based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found item based on the parameters in the current route, + * or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + const policyId = route.queryParamMap.get('policyId'); + + if (isEmpty(policyId)) { + this.router.navigateByUrl('/404', { skipLocationChange: true }); + } + + return this.resourcePolicyService.findById(policyId, followLink('eperson'), followLink('group')).pipe( + find((RD) => hasValue(RD.error) || RD.hasSucceeded), + ); + } +} diff --git a/src/app/shared/resource-policies/resource-policies.component.html b/src/app/shared/resource-policies/resource-policies.component.html new file mode 100644 index 0000000000..b06946ad25 --- /dev/null +++ b/src/app/shared/resource-policies/resource-policies.component.html @@ -0,0 +1,99 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ {{ 'resource-policies.table.headers.title.for.' + resourceType | translate }} {{resourceUUID}} +
+ + +
+
+
+
+ + +
+
{{'resource-policies.table.headers.id' | translate}}{{'resource-policies.table.headers.name' | translate}}{{'resource-policies.table.headers.policyType' | translate}}{{'resource-policies.table.headers.action' | translate}}{{'resource-policies.table.headers.eperson' | translate}}{{'resource-policies.table.headers.group' | translate}}{{'resource-policies.table.headers.date.start' | translate}}{{'resource-policies.table.headers.date.end' | translate}}{{'resource-policies.table.headers.edit' | translate}}
+
+ + +
+
+ {{entry.id}} + {{entry.policy.name}}{{entry.policy.policyType}}{{entry.policy.action}} + {{getEPersonName(entry.policy) | async}} + + {{getGroupName(entry.policy) | async}} + {{formatDate(entry.policy.startDate)}}{{formatDate(entry.policy.endDate)}} + +
+ + +
+
+
diff --git a/src/app/shared/resource-policies/resource-policies.component.scss b/src/app/shared/resource-policies/resource-policies.component.scss new file mode 100644 index 0000000000..0d9329e760 --- /dev/null +++ b/src/app/shared/resource-policies/resource-policies.component.scss @@ -0,0 +1,3 @@ +td .btn-link:focus { + box-shadow: none !important; +} diff --git a/src/app/shared/resource-policies/resource-policies.component.spec.ts b/src/app/shared/resource-policies/resource-policies.component.spec.ts new file mode 100644 index 0000000000..bab9eb4846 --- /dev/null +++ b/src/app/shared/resource-policies/resource-policies.component.spec.ts @@ -0,0 +1,485 @@ +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; + +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; + +import { Bitstream } from '../../core/shared/bitstream.model'; +import { Bundle } from '../../core/shared/bundle.model'; +import { createMockRDPaginatedObs } from '../../+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec'; +import { Item } from '../../core/shared/item.model'; +import { LinkService } from '../../core/cache/builders/link.service'; +import { getMockLinkService } from '../mocks/link-service.mock'; +import { createSuccessfulRemoteDataObject } from '../remote-data.utils'; +import { createTestComponent } from '../testing/utils.test'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationsServiceStub } from '../testing/notifications-service.stub'; +import { ResourcePolicyService } from '../../core/resource-policy/resource-policy.service'; +import { getMockResourcePolicyService } from '../mocks/mock-resource-policy-service'; +import { GroupDataService } from '../../core/eperson/group-data.service'; +import { RequestService } from '../../core/data/request.service'; +import { getMockRequestService } from '../mocks/request.service.mock'; +import { RouterStub } from '../testing/router.stub'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { ResourcePoliciesComponent } from './resource-policies.component'; +import { PolicyType } from '../../core/resource-policy/models/policy-type.model'; +import { ActionType } from '../../core/resource-policy/models/action-type.model'; +import { EPersonMock } from '../testing/eperson.mock'; +import { GroupMock } from '../testing/group-mock'; + +describe('ResourcePoliciesComponent test suite', () => { + let comp: ResourcePoliciesComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let de; + let routerStub: any; + let scheduler: TestScheduler; + const notificationsServiceStub = new NotificationsServiceStub(); + const resourcePolicyService: any = getMockResourcePolicyService(); + const linkService: any = getMockLinkService(); + + const resourcePolicy: any = { + id: '1', + name: null, + description: null, + policyType: PolicyType.TYPE_SUBMISSION, + action: ActionType.READ, + startDate: null, + endDate: null, + type: 'resourcepolicy', + uuid: 'resource-policy-1', + _links: { + eperson: { + href: 'https://rest.api/rest/api/eperson' + }, + group: { + href: 'https://rest.api/rest/api/group' + }, + self: { + href: 'https://rest.api/rest/api/resourcepolicies/1' + }, + }, + eperson: observableOf(createSuccessfulRemoteDataObject({})), + group: observableOf(createSuccessfulRemoteDataObject(GroupMock)) + }; + + const anotherResourcePolicy: any = { + id: '2', + name: null, + description: null, + policyType: PolicyType.TYPE_SUBMISSION, + action: ActionType.WRITE, + startDate: null, + endDate: null, + type: 'resourcepolicy', + uuid: 'resource-policy-2', + _links: { + eperson: { + href: 'https://rest.api/rest/api/eperson' + }, + group: { + href: 'https://rest.api/rest/api/group' + }, + self: { + href: 'https://rest.api/rest/api/resourcepolicies/1' + }, + }, + eperson: observableOf(createSuccessfulRemoteDataObject(EPersonMock)), + group: observableOf(createSuccessfulRemoteDataObject({})) + }; + + const bitstream1 = Object.assign(new Bitstream(), { + id: 'bitstream1', + uuid: 'bitstream1' + }); + const bitstream2 = Object.assign(new Bitstream(), { + id: 'bitstream2', + uuid: 'bitstream2' + }); + const bitstream3 = Object.assign(new Bitstream(), { + id: 'bitstream3', + uuid: 'bitstream3' + }); + const bitstream4 = Object.assign(new Bitstream(), { + id: 'bitstream4', + uuid: 'bitstream4' + }); + const bundle1 = Object.assign(new Bundle(), { + id: 'bundle1', + uuid: 'bundle1', + _links: { + self: { href: 'bundle1-selflink' } + }, + bitstreams: createMockRDPaginatedObs([bitstream1, bitstream2]) + }); + const bundle2 = Object.assign(new Bundle(), { + id: 'bundle2', + uuid: 'bundle2', + _links: { + self: { href: 'bundle2-selflink' } + }, + bitstreams: createMockRDPaginatedObs([bitstream3, bitstream4]) + }); + + const item = Object.assign(new Item(), { + uuid: 'itemUUID', + id: 'itemUUID', + _links: { + self: { href: 'item-selflink' } + }, + bundles: createMockRDPaginatedObs([bundle1, bundle2]) + }); + + const routeStub = { + data: observableOf({ + item: createSuccessfulRemoteDataObject(item) + }) + }; + + const epersonService = jasmine.createSpyObj('epersonService', { + findByHref: jasmine.createSpy('findByHref'), + }); + + const groupService = jasmine.createSpyObj('groupService', { + findByHref: jasmine.createSpy('findByHref'), + }); + + routerStub = Object.assign(new RouterStub(), { + url: `url/edit` + }); + + const getInitEntries = () => { + return [ + Object.assign({}, { + id: resourcePolicy.id, + policy: resourcePolicy, + checked: false + }), + Object.assign({}, { + id: anotherResourcePolicy.id, + policy: anotherResourcePolicy, + checked: false + }) + ] + } + + const resourcePolicySelectedEntries = [ + { + id: resourcePolicy.id, + policy: resourcePolicy, + checked: true + }, + { + id: anotherResourcePolicy.id, + policy: anotherResourcePolicy, + checked: false + } + ]; + + const pageInfo = new PageInfo(); + const array = [resourcePolicy, anotherResourcePolicy]; + const paginatedList = new PaginatedList(pageInfo, array); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + TranslateModule.forRoot() + ], + declarations: [ + ResourcePoliciesComponent, + TestComponent + ], + providers: [ + { provide: LinkService, useValue: linkService }, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: EPersonDataService, useValue: epersonService }, + { provide: GroupDataService, useValue: groupService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, + { provide: ResourcePolicyService, useValue: resourcePolicyService }, + { provide: RequestService, useValue: getMockRequestService() }, + { provide: Router, useValue: routerStub }, + ChangeDetectorRef, + ResourcePoliciesComponent + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create ResourcePoliciesComponent', inject([ResourcePoliciesComponent], (app: ResourcePoliciesComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ResourcePoliciesComponent); + comp = fixture.componentInstance; + compAsAny = fixture.componentInstance; + linkService.resolveLink.and.callFake((object, link) => object); + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + it('should init component properly', () => { + spyOn(comp, 'initResourcePolicyLIst'); + fixture.detectChanges(); + expect(compAsAny.isActive).toBeTruthy(); + expect(comp.initResourcePolicyLIst).toHaveBeenCalled(); + }); + + it('should init resource policies list properly', () => { + const expected = getInitEntries(); + compAsAny.isActive = true; + resourcePolicyService.searchByResource.and.returnValue(hot('a|', { + a: paginatedListRD + })); + + scheduler = getTestScheduler(); + scheduler.schedule(() => comp.initResourcePolicyLIst()); + scheduler.flush(); + + expect(compAsAny.resourcePoliciesEntries$.value).toEqual(expected); + }); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ResourcePoliciesComponent); + comp = fixture.componentInstance; + compAsAny = fixture.componentInstance; + linkService.resolveLink.and.callFake((object, link) => object); + compAsAny.isActive = true; + const initResourcePolicyEntries = getInitEntries(); + compAsAny.resourcePoliciesEntries$.next(initResourcePolicyEntries); + resourcePolicyService.searchByResource.and.returnValue(observableOf({})); + spyOn(comp, 'initResourcePolicyLIst').and.callFake(() => ({})); + fixture.detectChanges(); + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + describe('canDelete', () => { + beforeEach(() => { + const initResourcePolicyEntries = getInitEntries(); + compAsAny.resourcePoliciesEntries$.next(initResourcePolicyEntries); + fixture.detectChanges(); + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + it('should return false when no row is selected', () => { + expect(comp.canDelete()).toBeObservable(cold('(a|)', { + a: false + })); + }); + + it('should return true when al least is selected', () => { + const checkbox = fixture.debugElement.query(By.css('table > tbody > tr:nth-child(1) input')); + const event = { target: { checked: true } }; + checkbox.triggerEventHandler('change', event); + expect(comp.canDelete()).toBeObservable(cold('(a|)', { + a: true + })); + }); + }); + + it('should render a table with a row for each policy', () => { + const rows = fixture.debugElement.queryAll(By.css('table > tbody > tr')); + expect(rows.length).toBe(2); + }); + + describe('deleteSelectedResourcePolicies', () => { + beforeEach(() => { + compAsAny.resourcePoliciesEntries$.next(resourcePolicySelectedEntries); + fixture.detectChanges(); + }); + + it('should notify success when delete is successful', () => { + + resourcePolicyService.delete.and.returnValue(observableOf(true)); + scheduler = getTestScheduler(); + scheduler.schedule(() => comp.deleteSelectedResourcePolicies()); + scheduler.flush(); + + expect(notificationsServiceStub.success).toHaveBeenCalled(); + expect(comp.initResourcePolicyLIst).toHaveBeenCalled(); + }); + + it('should notify error when delete is not successful', () => { + + resourcePolicyService.delete.and.returnValue(observableOf(false)); + scheduler = getTestScheduler(); + scheduler.schedule(() => comp.deleteSelectedResourcePolicies()); + scheduler.flush(); + + expect(notificationsServiceStub.error).toHaveBeenCalled(); + expect(comp.initResourcePolicyLIst).toHaveBeenCalled(); + }); + }); + + it('should get the resource\'s policy list', () => { + const initResourcePolicyEntries = getInitEntries(); + expect(comp.getResourcePolicies()).toBeObservable(cold('a', { + a: initResourcePolicyEntries + })); + + }); + + describe('hasEPerson', () => { + it('should true when policy is link to the eperson', () => { + + expect(comp.hasEPerson(anotherResourcePolicy)).toBeObservable(cold('(ab|)', { + a: false, + b: true + })); + + }); + + it('should false when policy is not link to the eperson', () => { + + expect(comp.hasEPerson(resourcePolicy)).toBeObservable(cold('(aa|)', { + a: false + })); + + }); + }); + + describe('hasGroup', () => { + it('should true when policy is link to the group', () => { + + expect(comp.hasGroup(resourcePolicy)).toBeObservable(cold('(ab|)', { + a: false, + b: true + })); + + }); + + it('should false when policy is not link to the group', () => { + + expect(comp.hasGroup(anotherResourcePolicy)).toBeObservable(cold('(aa|)', { + a: false + })); + + }); + }); + + describe('getEPersonName', () => { + it('should return the eperson name', () => { + + expect(comp.getEPersonName(anotherResourcePolicy)).toBeObservable(cold('(ab|)', { + a: '', + b: 'User Test' + })); + }); + }); + + describe('getGroupName', () => { + it('should return the group name', () => { + + expect(comp.getGroupName(resourcePolicy)).toBeObservable(cold('(ab|)', { + a: '', + b: 'testgroupname' + })); + }); + }); + + it('should format date properly', () => { + expect(comp.formatDate('2020-04-14T12:00:00Z')).toBe('2020-04-14'); + }); + + it('should select All Checkbox', () => { + spyOn(comp, 'selectAllCheckbox').and.callThrough(); + const checkbox = fixture.debugElement.query(By.css('table > thead > tr:nth-child(2) input')); + + const event = { target: { checked: true } }; + checkbox.triggerEventHandler('change', event); + expect(comp.selectAllCheckbox).toHaveBeenCalled(); + }); + + it('should select a Checkbox', () => { + spyOn(comp, 'selectCheckbox').and.callThrough(); + const checkbox = fixture.debugElement.query(By.css('table > tbody > tr:nth-child(1) input')); + + const event = { target: { checked: true } }; + checkbox.triggerEventHandler('change', event); + expect(comp.selectCheckbox).toHaveBeenCalled(); + }); + + it('should redirect to create resource policy page', () => { + + comp.redirectToResourcePolicyCreatePage(); + expect(compAsAny.router.navigate).toHaveBeenCalled(); + }); + + it('should redirect to resource policy edit page', () => { + + comp.redirectToResourcePolicyEditPage(resourcePolicy); + expect(compAsAny.router.navigate).toHaveBeenCalled(); + }); + + it('should redirect to resource policy edit page', () => { + compAsAny.groupService.findByHref.and.returnValue(observableOf(createSuccessfulRemoteDataObject(GroupMock))); + + comp.redirectToGroupEditPage(resourcePolicy); + expect(compAsAny.router.navigate).toHaveBeenCalled(); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + resourceUUID = 'itemUUID'; + resourceType = 'item'; +} diff --git a/src/app/shared/resource-policies/resource-policies.component.ts b/src/app/shared/resource-policies/resource-policies.component.ts new file mode 100644 index 0000000000..bf5f584e98 --- /dev/null +++ b/src/app/shared/resource-policies/resource-policies.component.ts @@ -0,0 +1,345 @@ +import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { BehaviorSubject, from as observableFrom, Observable, Subscription } from 'rxjs'; +import { concatMap, distinctUntilChanged, filter, map, reduce, scan, startWith, take } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; + +import { ResourcePolicyService } from '../../core/resource-policy/resource-policy.service'; +import { + getFirstSucceededRemoteDataPayload, + getFirstSucceededRemoteDataWithNotEmptyPayload, + getSucceededRemoteData +} from '../../core/shared/operators'; +import { ResourcePolicy } from '../../core/resource-policy/models/resource-policy.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { Group } from '../../core/eperson/models/group.model'; +import { GroupDataService } from '../../core/eperson/group-data.service'; +import { hasValue, isEmpty, isNotEmpty } from '../empty.util'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { RequestService } from '../../core/data/request.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { dateToString, stringToNgbDateStruct } from '../date.util'; +import { followLink } from '../utils/follow-link-config.model'; +import { ADMIN_MODULE_PATH } from '../../app-routing.module'; +import { ACCESS_CONTROL_MODULE_PATH } from '../../+admin/admin-routing.module'; +import { GROUP_EDIT_PATH } from '../../+admin/admin-access-control/admin-access-control-routing.module'; + +interface ResourcePolicyCheckboxEntry { + id: string; + policy: ResourcePolicy; + checked: boolean +} + +@Component({ + selector: 'ds-resource-policies', + styleUrls: ['./resource-policies.component.scss'], + templateUrl: './resource-policies.component.html' +}) +/** + * Component that shows the policies for given resource + */ +export class ResourcePoliciesComponent implements OnInit, OnDestroy { + + /** + * The resource UUID + * @type {string} + */ + @Input() public resourceUUID: string; + + /** + * The resource type (e.g. 'item', 'bundle' etc) used as key to build automatically translation label + * @type {string} + */ + @Input() public resourceType: string; + + /** + * A boolean representing if component is active + * @type {boolean} + */ + private isActive: boolean; + + /** + * A boolean representing if a submission delete operation is pending + * @type {BehaviorSubject} + */ + private processingDelete$ = new BehaviorSubject(false); + + /** + * The list of policies for given resource + * @type {BehaviorSubject} + */ + private resourcePoliciesEntries$: BehaviorSubject = + new BehaviorSubject([]); + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {ChangeDetectorRef} cdr + * @param {DSONameService} dsoNameService + * @param {EPersonDataService} ePersonService + * @param {GroupDataService} groupService + * @param {NotificationsService} notificationsService + * @param {RequestService} requestService + * @param {ResourcePolicyService} resourcePolicyService + * @param {ActivatedRoute} route + * @param {Router} router + * @param {TranslateService} translate + */ + constructor( + private cdr: ChangeDetectorRef, + private dsoNameService: DSONameService, + private ePersonService: EPersonDataService, + private groupService: GroupDataService, + private notificationsService: NotificationsService, + private requestService: RequestService, + private resourcePolicyService: ResourcePolicyService, + private route: ActivatedRoute, + private router: Router, + private translate: TranslateService + ) { + } + + /** + * Initialize the component, setting up the resource's policies + */ + ngOnInit(): void { + this.isActive = true; + this.initResourcePolicyLIst(); + } + + /** + * Check if there are any selected resource's policies to be deleted + * + * @return {Observable} + */ + canDelete(): Observable { + return observableFrom(this.resourcePoliciesEntries$.value).pipe( + filter((entry: ResourcePolicyCheckboxEntry) => entry.checked), + reduce((acc: any, value: any) => [...acc, ...value], []), + map((entries: ResourcePolicyCheckboxEntry[]) => isNotEmpty(entries)), + distinctUntilChanged() + ) + } + + /** + * Delete the selected resource's policies + */ + deleteSelectedResourcePolicies(): void { + this.processingDelete$.next(true); + const policiesToDelete: ResourcePolicyCheckboxEntry[] = this.resourcePoliciesEntries$.value + .filter((entry: ResourcePolicyCheckboxEntry) => entry.checked); + this.subs.push( + observableFrom(policiesToDelete).pipe( + concatMap((entry: ResourcePolicyCheckboxEntry) => this.resourcePolicyService.delete(entry.policy.id)), + scan((acc: any, value: any) => [...acc, ...value], []), + filter((results: boolean[]) => results.length === policiesToDelete.length), + take(1), + ).subscribe((results: boolean[]) => { + const failureResults = results.filter((result: boolean) => !result); + if (isEmpty(failureResults)) { + this.notificationsService.success(null, this.translate.get('resource-policies.delete.success.content')); + } else { + this.notificationsService.error(null, this.translate.get('resource-policies.delete.failure.content')); + } + this.initResourcePolicyLIst(); + this.processingDelete$.next(false); + }) + ) + } + + /** + * Returns a date in simplified format (YYYY-MM-DD). + * + * @param date + * @return a string with formatted date + */ + formatDate(date: string): string { + return isNotEmpty(date) ? dateToString(stringToNgbDateStruct(date)) : ''; + } + + /** + * Return the ePerson's name which the given policy is linked to + * + * @param policy The resource policy + */ + getEPersonName(policy: ResourcePolicy): Observable { + // TODO to be reviewed when https://github.com/DSpace/dspace-angular/issues/644 will be resolved + // return this.ePersonService.findByHref(policy._links.eperson.href).pipe( + return policy.eperson.pipe( + filter(() => this.isActive), + getFirstSucceededRemoteDataWithNotEmptyPayload(), + map((eperson: EPerson) => this.dsoNameService.getName(eperson)), + startWith('') + ) + } + + /** + * Return the group's name which the given policy is linked to + * + * @param policy The resource policy + */ + getGroupName(policy: ResourcePolicy): Observable { + // TODO to be reviewed when https://github.com/DSpace/dspace-angular/issues/644 will be resolved + // return this.groupService.findByHref(policy._links.group.href).pipe( + return policy.group.pipe( + filter(() => this.isActive), + getFirstSucceededRemoteDataWithNotEmptyPayload(), + map((group: Group) => this.dsoNameService.getName(group)), + startWith('') + ) + } + + /** + * Return all resource's policies + * + * @return an observable that emits all resource's policies + */ + getResourcePolicies(): Observable { + return this.resourcePoliciesEntries$.asObservable(); + } + + /** + * Check whether the given policy is linked to a ePerson + * + * @param policy The resource policy + * @return an observable that emits true when the policy is linked to a ePerson, false otherwise + */ + hasEPerson(policy): Observable { + // TODO to be reviewed when https://github.com/DSpace/dspace-angular/issues/644 will be resolved + // return this.ePersonService.findByHref(policy._links.eperson.href).pipe( + return policy.eperson.pipe( + filter(() => this.isActive), + getFirstSucceededRemoteDataPayload(), + map((eperson: EPerson) => isNotEmpty(eperson)), + startWith(false) + ) + } + + /** + * Check whether the given policy is linked to a group + * + * @param policy The resource policy + * @return an observable that emits true when the policy is linked to a group, false otherwise + */ + hasGroup(policy): Observable { + // TODO to be reviewed when https://github.com/DSpace/dspace-angular/issues/644 will be resolved + // return this.groupService.findByHref(policy._links.group.href).pipe( + return policy.group.pipe( + filter(() => this.isActive), + getFirstSucceededRemoteDataPayload(), + map((group: Group) => isNotEmpty(group)), + startWith(false) + ) + } + + /** + * Initialize the resource's policies list + */ + initResourcePolicyLIst() { + this.resourcePolicyService.searchByResource(this.resourceUUID, null, + followLink('eperson'), followLink('group')).pipe( + filter(() => this.isActive), + getSucceededRemoteData(), + take(1) + ).subscribe((result) => { + const entries = result.payload.page.map((policy: ResourcePolicy) => ({ + id: policy.id, + policy: policy, + checked: false + })); + this.resourcePoliciesEntries$.next(entries); + // Remove cached request + this.requestService.removeByHrefSubstring(this.resourceUUID); + this.cdr.detectChanges(); + }); + } + + /** + * Return a boolean representing if a delete operation is pending + * + * @return {Observable} + */ + isProcessingDelete(): Observable { + return this.processingDelete$.asObservable(); + } + + /** + * Redirect to resource policy creation page + */ + redirectToResourcePolicyCreatePage(): void { + this.router.navigate([`../create`], { + relativeTo: this.route, + queryParams: { + policyTargetId: this.resourceUUID, + targetType: this.resourceType + } + }) + } + + /** + * Redirect to resource policy editing page + * + * @param policy The resource policy + */ + redirectToResourcePolicyEditPage(policy: ResourcePolicy): void { + this.router.navigate([`../edit`], { + relativeTo: this.route, + queryParams: { + policyId: policy.id + } + }) + } + + /** + * Redirect to group edit page + * + * @param policy The resource policy + */ + redirectToGroupEditPage(policy: ResourcePolicy): void { + this.requestService.removeByHrefSubstring(policy._links.group.href); + this.subs.push( + this.groupService.findByHref(policy._links.group.href).pipe( + filter(() => this.isActive), + getFirstSucceededRemoteDataPayload(), + map((group: Group) => group.id) + ).subscribe((groupUUID) => { + this.router.navigate([ADMIN_MODULE_PATH, ACCESS_CONTROL_MODULE_PATH, GROUP_EDIT_PATH, groupUUID]) + }) + ) + } + + /** + * Select/unselect all checkbox in the list + */ + selectAllCheckbox(event: any): void { + const checked = event.target.checked; + this.resourcePoliciesEntries$.value.forEach((entry: ResourcePolicyCheckboxEntry) => entry.checked = checked); + } + + /** + * Select/unselect checkbox + */ + selectCheckbox(policyEntry: ResourcePolicyCheckboxEntry, checked: boolean) { + policyEntry.checked = checked; + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.isActive = false; + this.resourcePoliciesEntries$ = null; + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()) + } + +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 1d089cf647..67d7db5c5d 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -194,6 +194,14 @@ import { ClaimedTaskActionsLoaderComponent } from './mydspace-actions/claimed-ta import { ClaimedTaskActionsDirective } from './mydspace-actions/claimed-task/switcher/claimed-task-actions.directive'; import { ClaimedTaskActionsEditMetadataComponent } from './mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component'; import { ImpersonateNavbarComponent } from './impersonate-navbar/impersonate-navbar.component'; +import { ResourcePoliciesComponent } from './resource-policies/resource-policies.component'; +import { NgForTrackByIdDirective } from './ng-for-track-by-id.directive'; +import { ResourcePolicyFormComponent } from './resource-policies/form/resource-policy-form.component'; +import { EpersonGroupListComponent } from './resource-policies/form/eperson-group-list/eperson-group-list.component'; +import { ResourcePolicyTargetResolver } from './resource-policies/resolvers/resource-policy-target.resolver'; +import { ResourcePolicyResolver } from './resource-policies/resolvers/resource-policy.resolver'; +import { EpersonSearchBoxComponent } from './resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component'; +import { GroupSearchBoxComponent } from './resource-policies/form/eperson-group-list/group-search-box/group-search-box.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -373,7 +381,12 @@ const COMPONENTS = [ PublicationSearchResultListElementComponent, ItemVersionsNoticeComponent, ModifyItemOverviewComponent, - ImpersonateNavbarComponent + ImpersonateNavbarComponent, + ResourcePoliciesComponent, + ResourcePolicyFormComponent, + EpersonGroupListComponent, + EpersonSearchBoxComponent, + GroupSearchBoxComponent ]; const ENTRY_COMPONENTS = [ @@ -461,7 +474,9 @@ const PROVIDERS = [ { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn - } + }, + ResourcePolicyResolver, + ResourcePolicyTargetResolver ]; const DIRECTIVES = [ @@ -475,7 +490,8 @@ const DIRECTIVES = [ RoleDirective, MetadataRepresentationDirective, ListableObjectDirective, - ClaimedTaskActionsDirective + ClaimedTaskActionsDirective, + NgForTrackByIdDirective ]; @NgModule({ diff --git a/src/app/shared/testing/group-mock.ts b/src/app/shared/testing/group-mock.ts index 04395ae8fa..d0ee135b98 100644 --- a/src/app/shared/testing/group-mock.ts +++ b/src/app/shared/testing/group-mock.ts @@ -14,6 +14,7 @@ export const GroupMock2: Group = Object.assign(new Group(), { subgroups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/subgroups' }, epersons: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons' } }, + _name: 'testgroupname2', id: 'testgroupid2', uuid: 'testgroupid2', type: 'group', @@ -32,6 +33,7 @@ export const GroupMock: Group = Object.assign(new Group(), { subgroups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/subgroups' }, epersons: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/epersons' } }, + _name: 'testgroupname', id: 'testgroupid', uuid: 'testgroupid', type: 'group', diff --git a/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts b/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts index 04852cc014..07318807b6 100644 --- a/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts +++ b/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts @@ -3,7 +3,7 @@ import { Component, Input, OnInit } from '@angular/core'; import { find } from 'rxjs/operators'; import { GroupDataService } from '../../../../core/eperson/group-data.service'; -import { ResourcePolicy } from '../../../../core/shared/resource-policy.model'; +import { ResourcePolicy } from '../../../../core/resource-policy/models/resource-policy.model'; import { isEmpty } from '../../../../shared/empty.util'; import { Group } from '../../../../core/eperson/models/group.model'; import { RemoteData } from '../../../../core/data/remote-data'; @@ -42,7 +42,7 @@ export class SubmissionSectionUploadAccessConditionsComponent implements OnInit ngOnInit() { this.accessConditions.forEach((accessCondition: ResourcePolicy) => { if (isEmpty(accessCondition.name)) { - this.groupService.findById(accessCondition.groupUUID).pipe( + this.groupService.findByHref(accessCondition._links.group.href).pipe( find((rd: RemoteData) => !rd.isResponsePending && rd.hasSucceeded)) .subscribe((rd: RemoteData) => { const group: Group = rd.payload; diff --git a/src/app/submission/sections/upload/section-upload.component.spec.ts b/src/app/submission/sections/upload/section-upload.component.spec.ts index 3024e68d8c..42fb700ad7 100644 --- a/src/app/submission/sections/upload/section-upload.component.spec.ts +++ b/src/app/submission/sections/upload/section-upload.component.spec.ts @@ -1,7 +1,10 @@ import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { cold } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; @@ -20,19 +23,17 @@ import { mockSubmissionId, mockSubmissionState, mockUploadConfigResponse, - mockUploadConfigResponseNotRequired, mockUploadFiles, + mockUploadConfigResponseNotRequired, + mockUploadFiles, } from '../../../shared/mocks/submission.mock'; -import { BrowserModule } from '@angular/platform-browser'; -import { CommonModule } from '@angular/common'; import { SubmissionUploadsConfigService } from '../../../core/config/submission-uploads-config.service'; import { SectionUploadService } from './section-upload.service'; import { SubmissionSectionUploadComponent } from './section-upload.component'; import { CollectionDataService } from '../../../core/data/collection-data.service'; import { GroupDataService } from '../../../core/eperson/group-data.service'; -import { cold, hot } from 'jasmine-marbles'; import { Collection } from '../../../core/shared/collection.model'; -import { ResourcePolicy } from '../../../core/shared/resource-policy.model'; -import { ResourcePolicyService } from '../../../core/data/resource-policy.service'; +import { ResourcePolicy } from '../../../core/resource-policy/models/resource-policy.model'; +import { ResourcePolicyService } from '../../../core/resource-policy/resource-policy.service'; import { ConfigData } from '../../../core/config/config-data'; import { PageInfo } from '../../../core/shared/page-info.model'; import { Group } from '../../../core/eperson/models/group.model'; diff --git a/src/app/submission/sections/upload/section-upload.component.ts b/src/app/submission/sections/upload/section-upload.component.ts index 482965b6b4..f25a859631 100644 --- a/src/app/submission/sections/upload/section-upload.component.ts +++ b/src/app/submission/sections/upload/section-upload.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectorRef, Component, Inject } from '@angular/core'; -import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subscription} from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, filter, find, flatMap, map, reduce, take, tap } from 'rxjs/operators'; import { SectionModelComponent } from '../models/section.model'; @@ -8,7 +8,7 @@ import { hasValue, isNotEmpty, isNotUndefined, isUndefined } from '../../../shar import { SectionUploadService } from './section-upload.service'; import { CollectionDataService } from '../../../core/data/collection-data.service'; import { GroupDataService } from '../../../core/eperson/group-data.service'; -import { ResourcePolicyService } from '../../../core/data/resource-policy.service'; +import { ResourcePolicyService } from '../../../core/resource-policy/resource-policy.service'; import { SubmissionUploadsConfigService } from '../../../core/config/submission-uploads-config.service'; import { SubmissionUploadsModel } from '../../../core/config/models/config-submission-uploads.model'; import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 03dc00bd81..4173fa1cf2 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -996,6 +996,11 @@ + "item.edit.authorizations.heading": "With this editor you can view and alter the policies of an item, plus alter policies of individual item components: bundles and bitstreams. Briefly, an item is a container of bundles, and bundles are containers of bitstreams. Containers usually have ADD/REMOVE/READ/WRITE policies, while bitstreams only have READ/WRITE policies.", + + "item.edit.authorizations.title": "Edit item's Policies", + + "item.bitstreams.upload.bundle": "Bundle", @@ -2066,6 +2071,100 @@ + "resource-policies.add.button": "Add", + + "resource-policies.add.for.": "Add a new policy", + + "resource-policies.add.for.bitstream": "Add a new Bitstream policy", + + "resource-policies.add.for.bundle": "Add a new Bundle policy", + + "resource-policies.add.for.item": "Add a new Item policy", + + "resource-policies.create.page.heading": "Create new resource policy for ", + + "resource-policies.create.page.failure.content": "An error occurred while creating the resource policy.", + + "resource-policies.create.page.success.content": "Operation successful", + + "resource-policies.create.page.title": "Create new resource policy", + + "resource-policies.delete.btn": "Delete selected", + + "resource-policies.delete.btn.title": "Delete selected resource policies", + + "resource-policies.delete.failure.content": "An error occurred while deleting selected resource policies.", + + "resource-policies.delete.success.content": "Operation successful", + + "resource-policies.edit.page.heading": "Edit resource policy ", + + "resource-policies.edit.page.failure.content": "An error occurred while editing the resource policy.", + + "resource-policies.edit.page.success.content": "Operation successful", + + "resource-policies.edit.page.title": "Edit resource policy", + + "resource-policies.form.action-type.label": "Select the action type", + + "resource-policies.form.action-type.required": "You must select the resource policy action.", + + "resource-policies.form.eperson-group-list.label": "The eperson or group that will be grant of the permission", + + "resource-policies.form.eperson-group-list.select.btn": "Select", + + "resource-policies.form.eperson-group-list.tab.eperson": "Search for a ePerson", + + "resource-policies.form.eperson-group-list.tab.group": "Search for a group", + + "resource-policies.form.eperson-group-list.table.headers.action": "Action", + + "resource-policies.form.eperson-group-list.table.headers.id": "ID", + + "resource-policies.form.eperson-group-list.table.headers.name": "Name", + + "resource-policies.form.date.end.label": "End Date", + + "resource-policies.form.date.start.label": "Start Date", + + "resource-policies.form.description.label": "Description", + + "resource-policies.form.name.label": "Name", + + "resource-policies.form.policy-type.label": "Select the policy type", + + "resource-policies.form.policy-type.required": "You must select the resource policy type.", + + "resource-policies.table.headers.action": "Action", + + "resource-policies.table.headers.date.end": "End Date", + + "resource-policies.table.headers.date.start": "Start Date", + + "resource-policies.table.headers.edit": "Edit", + + "resource-policies.table.headers.edit.group": "Edit group", + + "resource-policies.table.headers.edit.policy": "Edit policy", + + "resource-policies.table.headers.eperson": "EPerson", + + "resource-policies.table.headers.group": "Group", + + "resource-policies.table.headers.id": "ID", + + "resource-policies.table.headers.name": "Name", + + "resource-policies.table.headers.policyType": "type", + + "resource-policies.table.headers.title.for.bitstream": "Policies for Bitstream", + + "resource-policies.table.headers.title.for.bundle": "Policies for Bundle", + + "resource-policies.table.headers.title.for.item": "Policies for Item", + + + "search.description": "", "search.switch-configuration.title": "Show",