diff --git a/src/app/access-control/access-control-routing.module.ts b/src/app/access-control/access-control-routing.module.ts index e64b0d170a..6f6de6cb26 100644 --- a/src/app/access-control/access-control-routing.module.ts +++ b/src/app/access-control/access-control-routing.module.ts @@ -6,8 +6,13 @@ import { GroupsRegistryComponent } from './group-registry/groups-registry.compon import { GROUP_EDIT_PATH } from './access-control-routing-paths'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { GroupPageGuard } from './group-registry/group-page.guard'; -import { GroupAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; -import { SiteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { + GroupAdministratorGuard +} from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; +import { + SiteAdministratorGuard +} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { BulkAccessComponent } from './bulk-access/bulk-access.component'; @NgModule({ imports: [ @@ -47,7 +52,16 @@ import { SiteAdministratorGuard } from '../core/data/feature-authorization/featu }, data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' }, canActivate: [GroupPageGuard] - } + }, + { + path: 'bulk-access', + component: BulkAccessComponent, + resolve: { + breadcrumb: I18nBreadcrumbResolver + }, + data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' }, + canActivate: [SiteAdministratorGuard] + }, ]) ] }) diff --git a/src/app/access-control/access-control.module.ts b/src/app/access-control/access-control.module.ts index 47a971a882..3dc4b6cedc 100644 --- a/src/app/access-control/access-control.module.ts +++ b/src/app/access-control/access-control.module.ts @@ -12,6 +12,12 @@ import { GroupsRegistryComponent } from './group-registry/groups-registry.compon import { FormModule } from '../shared/form/form.module'; import { DYNAMIC_ERROR_MESSAGES_MATCHER, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core'; import { AbstractControl } from '@angular/forms'; +import { BulkAccessComponent } from './bulk-access/bulk-access.component'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { BulkAccessBrowseComponent } from './bulk-access/browse/bulk-access-browse.component'; +import { BulkAccessSettingsComponent } from './bulk-access/settings/bulk-access-settings.component'; +import { SearchModule } from '../shared/search/search.module'; +import { AccessControlFormModule } from '../shared/access-control-form-container/access-control-form.module'; /** * Condition for displaying error messages on email form field @@ -28,6 +34,9 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher = RouterModule, AccessControlRoutingModule, FormModule, + NgbAccordionModule, + SearchModule, + AccessControlFormModule, ], exports: [ MembersListComponent, @@ -39,6 +48,9 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher = GroupFormComponent, SubgroupsListComponent, MembersListComponent, + BulkAccessComponent, + BulkAccessBrowseComponent, + BulkAccessSettingsComponent, ], providers: [ { diff --git a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html new file mode 100644 index 0000000000..c716aedb8b --- /dev/null +++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html @@ -0,0 +1,67 @@ + + + +
+ +
+
+ + +
+
+
+
+ + +
+
+
+
diff --git a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.scss b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.spec.ts b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.spec.ts new file mode 100644 index 0000000000..87b2a8d568 --- /dev/null +++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.spec.ts @@ -0,0 +1,82 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { of } from 'rxjs'; +import { NgbAccordionModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +import { BulkAccessBrowseComponent } from './bulk-access-browse.component'; +import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; +import { SelectableObject } from '../../../shared/object-list/selectable-list/selectable-list.service.spec'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; + +describe('BulkAccessBrowseComponent', () => { + let component: BulkAccessBrowseComponent; + let fixture: ComponentFixture; + + const listID1 = 'id1'; + const value1 = 'Selected object'; + const value2 = 'Another selected object'; + + const selected1 = new SelectableObject(value1); + const selected2 = new SelectableObject(value2); + + const testSelection = { id: listID1, selection: [selected1, selected2] } ; + + const selectableListService = jasmine.createSpyObj('SelectableListService', ['getSelectableList', 'deselectAll']); + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NgbAccordionModule, + NgbNavModule, + TranslateModule.forRoot() + ], + declarations: [BulkAccessBrowseComponent], + providers: [ { provide: SelectableListService, useValue: selectableListService }, ], + schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BulkAccessBrowseComponent); + component = fixture.componentInstance; + (component as any).selectableListService.getSelectableList.and.returnValue(of(testSelection)); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + component = null; + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should have an initial active nav id of "search"', () => { + expect(component.activateId).toEqual('search'); + }); + + it('should have an initial pagination options object with default values', () => { + expect(component.paginationOptions$.getValue().id).toEqual('bas'); + expect(component.paginationOptions$.getValue().pageSize).toEqual(5); + expect(component.paginationOptions$.getValue().currentPage).toEqual(1); + }); + + it('should have an initial remote data with a paginated list as value', () => { + const list = buildPaginatedList(new PageInfo({ + 'elementsPerPage': 5, + 'totalElements': 2, + 'totalPages': 1, + 'currentPage': 1 + }), [selected1, selected2]) ; + const rd = createSuccessfulRemoteDataObject(list); + + expect(component.objectsSelected$.value).toEqual(rd); + }); + +}); diff --git a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts new file mode 100644 index 0000000000..e806e729c8 --- /dev/null +++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.ts @@ -0,0 +1,119 @@ +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; + +import { BehaviorSubject, Subscription } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; + +import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; +import { SelectableListState } from '../../../shared/object-list/selectable-list/selectable-list.reducer'; +import { RemoteData } from '../../../core/data/remote-data'; +import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { hasValue } from '../../../shared/empty.util'; + +@Component({ + selector: 'ds-bulk-access-browse', + templateUrl: 'bulk-access-browse.component.html', + styleUrls: ['./bulk-access-browse.component.scss'], + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) +export class BulkAccessBrowseComponent implements OnInit, OnDestroy { + + /** + * The selection list id + */ + @Input() listId!: string; + + /** + * The active nav id + */ + activateId = 'search'; + + /** + * The list of the objects already selected + */ + objectsSelected$: BehaviorSubject>> = new BehaviorSubject>>(null); + + /** + * The pagination options object used for the list of selected elements + */ + paginationOptions$: BehaviorSubject = new BehaviorSubject(Object.assign(new PaginationComponentOptions(), { + id: 'bas', + pageSize: 5, + currentPage: 1 + })); + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + */ + private subs: Subscription[] = []; + + constructor(private selectableListService: SelectableListService) {} + + /** + * Subscribe to selectable list updates + */ + ngOnInit(): void { + + this.subs.push( + this.selectableListService.getSelectableList(this.listId).pipe( + distinctUntilChanged(), + map((list: SelectableListState) => this.generatePaginatedListBySelectedElements(list)) + ).subscribe(this.objectsSelected$) + ); + } + + pageNext() { + this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { + currentPage: this.paginationOptions$.value.currentPage + 1 + })); + } + + pagePrev() { + this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { + currentPage: this.paginationOptions$.value.currentPage - 1 + })); + } + + private calculatePageCount(pageSize, totalCount = 0) { + // we suppose that if we have 0 items we want 1 empty page + return totalCount < pageSize ? 1 : Math.ceil(totalCount / pageSize); + } + + /** + * Generate The RemoteData object containing the list of the selected elements + * @param list + * @private + */ + private generatePaginatedListBySelectedElements(list: SelectableListState): RemoteData> { + const pageInfo = new PageInfo({ + elementsPerPage: this.paginationOptions$.value.pageSize, + totalElements: list?.selection.length, + totalPages: this.calculatePageCount(this.paginationOptions$.value.pageSize, list?.selection.length), + currentPage: this.paginationOptions$.value.currentPage + }); + if (pageInfo.currentPage > pageInfo.totalPages) { + pageInfo.currentPage = pageInfo.totalPages; + this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, { + currentPage: pageInfo.currentPage + })); + } + return createSuccessfulRemoteDataObject(buildPaginatedList(pageInfo, list?.selection || [])); + } + + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + this.selectableListService.deselectAll(this.listId); + } +} diff --git a/src/app/access-control/bulk-access/bulk-access.component.html b/src/app/access-control/bulk-access/bulk-access.component.html new file mode 100644 index 0000000000..382caf85f4 --- /dev/null +++ b/src/app/access-control/bulk-access/bulk-access.component.html @@ -0,0 +1,19 @@ +
+ +
+ + +
+ +
+ + +
+
+ + + diff --git a/src/app/access-control/bulk-access/bulk-access.component.scss b/src/app/access-control/bulk-access/bulk-access.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/access-control/bulk-access/bulk-access.component.spec.ts b/src/app/access-control/bulk-access/bulk-access.component.spec.ts new file mode 100644 index 0000000000..e9b253147d --- /dev/null +++ b/src/app/access-control/bulk-access/bulk-access.component.spec.ts @@ -0,0 +1,158 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { BulkAccessComponent } from './bulk-access.component'; +import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service'; +import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; +import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { Process } from '../../process-page/processes/process.model'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; + +describe('BulkAccessComponent', () => { + let component: BulkAccessComponent; + let fixture: ComponentFixture; + let bulkAccessControlService: any; + let selectableListService: any; + + const selectableListServiceMock = jasmine.createSpyObj('SelectableListService', ['getSelectableList', 'deselectAll']); + const bulkAccessControlServiceMock = jasmine.createSpyObj('bulkAccessControlService', ['createPayloadFile', 'executeScript']); + + const mockFormState = { + 'bitstream': [], + 'item': [ + { + 'name': 'embargo', + 'startDate': { + 'year': 2026, + 'month': 5, + 'day': 31 + }, + 'endDate': null + } + ], + 'state': { + 'item': { + 'toggleStatus': true, + 'accessMode': 'replace' + }, + 'bitstream': { + 'toggleStatus': false, + 'accessMode': '', + 'changesLimit': '', + 'selectedBitstreams': [] + } + } + }; + + const mockFile = { + 'uuids': [ + '1234', '5678' + ], + 'file': { } + }; + + const mockSettings: any = jasmine.createSpyObj('AccessControlFormContainerComponent', { + getValue: jasmine.createSpy('getValue'), + reset: jasmine.createSpy('reset') + }); + const selection: any[] = [{ indexableObject: { uuid: '1234' } }, { indexableObject: { uuid: '5678' } }]; + const selectableListState: SelectableListState = { id: 'test', selection }; + const expectedIdList = ['1234', '5678']; + + const selectableListStateEmpty: SelectableListState = { id: 'test', selection: [] }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + TranslateModule.forRoot() + ], + declarations: [ BulkAccessComponent ], + providers: [ + { provide: BulkAccessControlService, useValue: bulkAccessControlServiceMock }, + { provide: NotificationsService, useValue: NotificationsServiceStub }, + { provide: SelectableListService, useValue: selectableListServiceMock } + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BulkAccessComponent); + component = fixture.componentInstance; + bulkAccessControlService = TestBed.inject(BulkAccessControlService); + selectableListService = TestBed.inject(SelectableListService); + + }); + + afterEach(() => { + fixture.destroy(); + }); + + describe('when there are no elements selected', () => { + + beforeEach(() => { + + (component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListStateEmpty)); + fixture.detectChanges(); + component.settings = mockSettings; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should generate the id list by selected elements', () => { + expect(component.objectsSelected$.value).toEqual([]); + }); + + it('should disable the execute button when there are no objects selected', () => { + expect(component.canExport()).toBe(false); + }); + + }); + + describe('when there are elements selected', () => { + + beforeEach(() => { + + (component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListState)); + fixture.detectChanges(); + component.settings = mockSettings; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should generate the id list by selected elements', () => { + expect(component.objectsSelected$.value).toEqual(expectedIdList); + }); + + it('should enable the execute button when there are objects selected', () => { + component.objectsSelected$.next(['1234']); + expect(component.canExport()).toBe(true); + }); + + it('should call the settings reset method when reset is called', () => { + component.reset(); + expect(component.settings.reset).toHaveBeenCalled(); + }); + + it('should call the bulkAccessControlService executeScript method when submit is called', () => { + (component.settings as any).getValue.and.returnValue(mockFormState); + bulkAccessControlService.createPayloadFile.and.returnValue(mockFile); + bulkAccessControlService.executeScript.and.returnValue(createSuccessfulRemoteDataObject$(new Process())); + component.objectsSelected$.next(['1234']); + component.submit(); + expect(bulkAccessControlService.executeScript).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/access-control/bulk-access/bulk-access.component.ts b/src/app/access-control/bulk-access/bulk-access.component.ts new file mode 100644 index 0000000000..04724614cb --- /dev/null +++ b/src/app/access-control/bulk-access/bulk-access.component.ts @@ -0,0 +1,94 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; + +import { BehaviorSubject, Subscription } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; + +import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component'; +import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service'; +import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer'; +import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service'; + +@Component({ + selector: 'ds-bulk-access', + templateUrl: './bulk-access.component.html', + styleUrls: ['./bulk-access.component.scss'] +}) +export class BulkAccessComponent implements OnInit { + + /** + * The selection list id + */ + listId = 'bulk-access-list'; + + /** + * The list of the objects already selected + */ + objectsSelected$: BehaviorSubject = new BehaviorSubject([]); + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + */ + private subs: Subscription[] = []; + + /** + * The SectionsDirective reference + */ + @ViewChild('dsBulkSettings') settings: BulkAccessSettingsComponent; + + constructor( + private bulkAccessControlService: BulkAccessControlService, + private selectableListService: SelectableListService + ) { + } + + ngOnInit(): void { + this.subs.push( + this.selectableListService.getSelectableList(this.listId).pipe( + distinctUntilChanged(), + map((list: SelectableListState) => this.generateIdListBySelectedElements(list)) + ).subscribe(this.objectsSelected$) + ); + } + + canExport(): boolean { + return this.objectsSelected$.value?.length > 0; + } + + /** + * Reset the form to its initial state + * This will also reset the state of the child components (bitstream and item access) + */ + reset(): void { + this.settings.reset(); + } + + /** + * Submit the form + * This will create a payload file and execute the script + */ + submit(): void { + const settings = this.settings.getValue(); + const bitstreamAccess = settings.bitstream; + const itemAccess = settings.item; + + const { file } = this.bulkAccessControlService.createPayloadFile({ + bitstreamAccess, + itemAccess, + state: settings.state + }); + + this.bulkAccessControlService.executeScript( + this.objectsSelected$.value || [], + file + ).subscribe(); + } + + /** + * Generate The RemoteData object containing the list of the selected elements + * @param list + * @private + */ + private generateIdListBySelectedElements(list: SelectableListState): string[] { + return list?.selection?.map((entry: any) => entry.indexableObject.uuid); + } +} diff --git a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.html b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.html new file mode 100644 index 0000000000..01f36ef03f --- /dev/null +++ b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.html @@ -0,0 +1,21 @@ + + + +
+ +
+
+ + +
+
+
+
+ + + +
+
diff --git a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.scss b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts new file mode 100644 index 0000000000..14e0fdefb2 --- /dev/null +++ b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.spec.ts @@ -0,0 +1,81 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { BulkAccessSettingsComponent } from './bulk-access-settings.component'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +describe('BulkAccessSettingsComponent', () => { + let component: BulkAccessSettingsComponent; + let fixture: ComponentFixture; + const mockFormState = { + 'bitstream': [], + 'item': [ + { + 'name': 'embargo', + 'startDate': { + 'year': 2026, + 'month': 5, + 'day': 31 + }, + 'endDate': null + } + ], + 'state': { + 'item': { + 'toggleStatus': true, + 'accessMode': 'replace' + }, + 'bitstream': { + 'toggleStatus': false, + 'accessMode': '', + 'changesLimit': '', + 'selectedBitstreams': [] + } + } + }; + + const mockControl: any = jasmine.createSpyObj('AccessControlFormContainerComponent', { + getFormValue: jasmine.createSpy('getFormValue'), + reset: jasmine.createSpy('reset') + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NgbAccordionModule, TranslateModule.forRoot()], + declarations: [BulkAccessSettingsComponent], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BulkAccessSettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + component.controlForm = mockControl; + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should have a method to get the form value', () => { + expect(component.getValue).toBeDefined(); + }); + + it('should have a method to reset the form', () => { + expect(component.reset).toBeDefined(); + }); + + it('should return the correct form value', () => { + const expectedValue = mockFormState; + (component.controlForm as any).getFormValue.and.returnValue(mockFormState); + const actualValue = component.getValue(); + // @ts-ignore + expect(actualValue).toEqual(expectedValue); + }); + + it('should call reset on the control form', () => { + component.reset(); + expect(component.controlForm.reset).toHaveBeenCalled(); + }); +}); diff --git a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts new file mode 100644 index 0000000000..eecc016245 --- /dev/null +++ b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.ts @@ -0,0 +1,34 @@ +import { Component, ViewChild } from '@angular/core'; +import { + AccessControlFormContainerComponent +} from '../../../shared/access-control-form-container/access-control-form-container.component'; + +@Component({ + selector: 'ds-bulk-access-settings', + templateUrl: 'bulk-access-settings.component.html', + styleUrls: ['./bulk-access-settings.component.scss'], + exportAs: 'dsBulkSettings' +}) +export class BulkAccessSettingsComponent { + + /** + * The SectionsDirective reference + */ + @ViewChild('dsAccessControlForm') controlForm: AccessControlFormContainerComponent; + + /** + * Will be used from a parent component to read the value of the form + */ + getValue() { + return this.controlForm.getFormValue(); + } + + /** + * Reset the form to its initial state + * This will also reset the state of the child components (bitstream and item access) + */ + reset() { + this.controlForm.reset(); + } + +} diff --git a/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.html b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.html new file mode 100644 index 0000000000..4e957cf867 --- /dev/null +++ b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.html @@ -0,0 +1,7 @@ + + + diff --git a/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.scss b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts new file mode 100644 index 0000000000..04da8bbcd9 --- /dev/null +++ b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CollectionAccessControlComponent } from './collection-access-control.component'; + +xdescribe('CollectionAccessControlComponent', () => { + let component: CollectionAccessControlComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ CollectionAccessControlComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CollectionAccessControlComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.ts b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.ts new file mode 100644 index 0000000000..4192fe5a9a --- /dev/null +++ b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.ts @@ -0,0 +1,24 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Community } from '../../../core/shared/community.model'; +import { ActivatedRoute } from '@angular/router'; +import { map } from 'rxjs/operators'; +import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; + +@Component({ + selector: 'ds-collection-access-control', + templateUrl: './collection-access-control.component.html', + styleUrls: ['./collection-access-control.component.scss'], +}) +export class CollectionAccessControlComponent implements OnInit { + itemRD$: Observable>; + + constructor(private route: ActivatedRoute) {} + + ngOnInit(): void { + this.itemRD$ = this.route.parent.parent.data.pipe( + map((data) => data.dso) + ).pipe(getFirstSucceededRemoteData()) as Observable>; + } +} diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts b/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts index 18f7feb699..8d0cb179f1 100644 --- a/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts +++ b/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts @@ -9,10 +9,14 @@ import { CollectionCurateComponent } from './collection-curate/collection-curate import { CollectionSourceComponent } from './collection-source/collection-source.component'; import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component'; import { CollectionFormModule } from '../collection-form/collection-form.module'; -import { CollectionSourceControlsComponent } from './collection-source/collection-source-controls/collection-source-controls.component'; +import { + CollectionSourceControlsComponent +} from './collection-source/collection-source-controls/collection-source-controls.component'; import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module'; import { FormModule } from '../../shared/form/form.module'; import { ComcolModule } from '../../shared/comcol/comcol.module'; +import { CollectionAccessControlComponent } from './collection-access-control/collection-access-control.component'; +import { AccessControlFormModule } from '../../shared/access-control-form-container/access-control-form.module'; /** * Module that contains all components related to the Edit Collection page administrator functionality @@ -26,6 +30,7 @@ import { ComcolModule } from '../../shared/comcol/comcol.module'; ResourcePoliciesModule, FormModule, ComcolModule, + AccessControlFormModule, ], declarations: [ EditCollectionPageComponent, @@ -33,7 +38,7 @@ import { ComcolModule } from '../../shared/comcol/comcol.module'; CollectionRolesComponent, CollectionCurateComponent, CollectionSourceComponent, - + CollectionAccessControlComponent, CollectionSourceControlsComponent, CollectionAuthorizationsComponent ] diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page.routing.module.ts b/src/app/collection-page/edit-collection-page/edit-collection-page.routing.module.ts index 92fc6efeff..c4481985c0 100644 --- a/src/app/collection-page/edit-collection-page/edit-collection-page.routing.module.ts +++ b/src/app/collection-page/edit-collection-page/edit-collection-page.routing.module.ts @@ -13,6 +13,7 @@ import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/cr import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver'; import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; import { CollectionAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard'; +import { CollectionAccessControlComponent } from './collection-access-control/collection-access-control.component'; /** * Routing module that handles the routing for the Edit Collection page administrator functionality @@ -58,6 +59,11 @@ import { CollectionAdministratorGuard } from '../../core/data/feature-authorizat component: CollectionCurateComponent, data: { title: 'collection.edit.tabs.curate.title', showBreadcrumbs: true } }, + { + path: 'access-control', + component: CollectionAccessControlComponent, + data: { title: 'collection.edit.tabs.access-control.title', showBreadcrumbs: true } + }, /* { path: 'authorizations', component: CollectionAuthorizationsComponent, diff --git a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.html b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.html new file mode 100644 index 0000000000..b4f2123b86 --- /dev/null +++ b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.html @@ -0,0 +1,6 @@ + + diff --git a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.scss b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts new file mode 100644 index 0000000000..d895cfd820 --- /dev/null +++ b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CommunityAccessControlComponent } from './community-access-control.component'; + +xdescribe('CommunityAccessControlComponent', () => { + let component: CommunityAccessControlComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ CommunityAccessControlComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CommunityAccessControlComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts new file mode 100644 index 0000000000..8a216e38df --- /dev/null +++ b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.ts @@ -0,0 +1,24 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ActivatedRoute } from '@angular/router'; +import { map } from 'rxjs/operators'; +import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; +import { Community } from '../../../core/shared/community.model'; + +@Component({ + selector: 'ds-community-access-control', + templateUrl: './community-access-control.component.html', + styleUrls: ['./community-access-control.component.scss'], +}) +export class CommunityAccessControlComponent implements OnInit { + itemRD$: Observable>; + + constructor(private route: ActivatedRoute) {} + + ngOnInit(): void { + this.itemRD$ = this.route.parent.parent.data.pipe( + map((data) => data.dso) + ).pipe(getFirstSucceededRemoteData()) as Observable>; + } +} diff --git a/src/app/community-page/edit-community-page/edit-community-page.module.ts b/src/app/community-page/edit-community-page/edit-community-page.module.ts index 0479ea6bc6..5190d6a008 100644 --- a/src/app/community-page/edit-community-page/edit-community-page.module.ts +++ b/src/app/community-page/edit-community-page/edit-community-page.module.ts @@ -10,6 +10,10 @@ import { CommunityAuthorizationsComponent } from './community-authorizations/com import { CommunityFormModule } from '../community-form/community-form.module'; import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module'; import { ComcolModule } from '../../shared/comcol/comcol.module'; +import { CommunityAccessControlComponent } from './community-access-control/community-access-control.component'; +import { + AccessControlFormModule +} from '../../shared/access-control-form-container/access-control-form.module'; /** * Module that contains all components related to the Edit Community page administrator functionality @@ -22,13 +26,15 @@ import { ComcolModule } from '../../shared/comcol/comcol.module'; CommunityFormModule, ComcolModule, ResourcePoliciesModule, + AccessControlFormModule, ], declarations: [ EditCommunityPageComponent, CommunityCurateComponent, CommunityMetadataComponent, CommunityRolesComponent, - CommunityAuthorizationsComponent + CommunityAuthorizationsComponent, + CommunityAccessControlComponent ] }) export class EditCommunityPageModule { diff --git a/src/app/community-page/edit-community-page/edit-community-page.routing.module.ts b/src/app/community-page/edit-community-page/edit-community-page.routing.module.ts index faebb1ef2e..994c6b5e96 100644 --- a/src/app/community-page/edit-community-page/edit-community-page.routing.module.ts +++ b/src/app/community-page/edit-community-page/edit-community-page.routing.module.ts @@ -11,6 +11,7 @@ import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/cr import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver'; import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; import { CommunityAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/community-administrator.guard'; +import { CommunityAccessControlComponent } from './community-access-control/community-access-control.component'; /** * Routing module that handles the routing for the Edit Community page administrator functionality @@ -51,6 +52,11 @@ import { CommunityAdministratorGuard } from '../../core/data/feature-authorizati component: CommunityCurateComponent, data: { title: 'community.edit.tabs.curate.title', showBreadcrumbs: true } }, + { + path: 'access-control', + component: CommunityAccessControlComponent, + data: { title: 'collection.edit.tabs.access-control.title', showBreadcrumbs: true } + }, /*{ path: 'authorizations', component: CommunityAuthorizationsComponent, diff --git a/src/app/core/config/bulk-access-config-data.service.ts b/src/app/core/config/bulk-access-config-data.service.ts new file mode 100644 index 0000000000..28b4029ea2 --- /dev/null +++ b/src/app/core/config/bulk-access-config-data.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@angular/core'; +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 { ConfigDataService } from './config-data.service'; +import { dataService } from '../data/base/data-service.decorator'; +import { BULK_ACCESS_CONDITION_OPTIONS } from './models/config-type'; + +/** + * Data Service responsible for retrieving Bulk Access Condition Options from the REST API + */ +@Injectable({ providedIn: 'root' }) +@dataService(BULK_ACCESS_CONDITION_OPTIONS) +export class BulkAccessConfigDataService extends ConfigDataService { + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super('bulkaccessconditionoptions', requestService, rdbService, objectCache, halService); + } + +} diff --git a/src/app/core/config/config-data.service.ts b/src/app/core/config/config-data.service.ts index 9ef2f11ad1..58b023e62c 100644 --- a/src/app/core/config/config-data.service.ts +++ b/src/app/core/config/config-data.service.ts @@ -1,16 +1,17 @@ import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + import { ConfigObject } from './models/config.model'; import { RemoteData } from '../data/remote-data'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; -import { map } from 'rxjs/operators'; -import { BaseDataService } from '../data/base/base-data.service'; +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; /** * Abstract data service to retrieve configuration objects from the REST server. * Common logic for configuration objects should be implemented here. */ -export abstract class ConfigDataService extends BaseDataService { +export abstract class ConfigDataService extends IdentifiableDataService { /** * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object @@ -37,4 +38,21 @@ export abstract class ConfigDataService extends BaseDataService { }), ); } + + /** + * Returns a config object by given name + * + * Throws an error if a configuration object cannot be retrieved. + * + * @param name The name of configuration to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + public findByName(name: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return super.findById(name, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } } diff --git a/src/app/core/config/models/bulk-access-condition-options.model.ts b/src/app/core/config/models/bulk-access-condition-options.model.ts new file mode 100644 index 0000000000..d84e14b95d --- /dev/null +++ b/src/app/core/config/models/bulk-access-condition-options.model.ts @@ -0,0 +1,38 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { HALLink } from '../../shared/hal-link.model'; +import { ConfigObject } from './config.model'; +import { AccessesConditionOption } from './config-accesses-conditions-options.model'; +import { BULK_ACCESS_CONDITION_OPTIONS } from './config-type'; + +/** + * Model class for a bulk access condition options + */ +@typedObject +@inheritSerialization(ConfigObject) +export class BulkAccessConditionOptions extends ConfigObject { + static type = BULK_ACCESS_CONDITION_OPTIONS; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + @autoserializeAs(String, 'name') + uuid: string; + + @autoserialize + id: string; + + @autoserialize + itemAccessConditionOptions: AccessesConditionOption[]; + + @autoserialize + bitstreamAccessConditionOptions: AccessesConditionOption[]; + + _links: { self: HALLink }; +} diff --git a/src/app/core/config/models/config-type.ts b/src/app/core/config/models/config-type.ts index 19864536f0..5733f5bfad 100644 --- a/src/app/core/config/models/config-type.ts +++ b/src/app/core/config/models/config-type.ts @@ -17,3 +17,5 @@ export const SUBMISSION_UPLOADS_TYPE = new ResourceType('submissionuploads'); export const SUBMISSION_UPLOAD_TYPE = new ResourceType('submissionupload'); export const SUBMISSION_ACCESSES_TYPE = new ResourceType('submissionaccessoption'); + +export const BULK_ACCESS_CONDITION_OPTIONS = new ResourceType('bulkaccessconditionoption'); diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 8cd24b0140..dbca773375 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -181,6 +181,7 @@ import { HierarchicalBrowseDefinition } from './shared/hierarchical-browse-defin import { FlatBrowseDefinition } from './shared/flat-browse-definition.model'; import { ValueListBrowseDefinition } from './shared/value-list-browse-definition.model'; import { NonHierarchicalBrowseDefinition } from './shared/non-hierarchical-browse-definition'; +import { BulkAccessConditionOptions } from './config/models/bulk-access-condition-options.model'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -379,6 +380,7 @@ export const models = IdentifierData, Subscription, ItemRequest, + BulkAccessConditionOptions ]; @NgModule({ diff --git a/src/app/core/shared/context.model.ts b/src/app/core/shared/context.model.ts index 756fe5c673..b4c02bee63 100644 --- a/src/app/core/shared/context.model.ts +++ b/src/app/core/shared/context.model.ts @@ -37,4 +37,6 @@ export enum Context { MyDSpaceApproved = 'mydspaceApproved', MyDSpaceWaitingController = 'mydspaceWaitingController', MyDSpaceValidation = 'mydspaceValidation', + + Bitstream = 'bitstream', } diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.ts b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.ts index 88f93d9d85..d804006eb1 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.ts +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.ts @@ -14,14 +14,4 @@ import { ItemSearchResultListElementComponent } from '../../../../../shared/obje */ export class JournalIssueSearchResultListElementComponent extends ItemSearchResultListElementComponent { - /** - * Display thumbnails if required by configuration - */ - showThumbnails: boolean; - - ngOnInit(): void { - super.ngOnInit(); - this.showThumbnails = this.appConfig.browseBy.showThumbnails; - } - } diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.ts b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.ts index ec98946937..2a01d20668 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.ts +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.ts @@ -14,14 +14,4 @@ import { ItemSearchResultListElementComponent } from '../../../../../shared/obje */ export class JournalVolumeSearchResultListElementComponent extends ItemSearchResultListElementComponent { - /** - * Display thumbnails if required by configuration - */ - showThumbnails: boolean; - - ngOnInit(): void { - super.ngOnInit(); - this.showThumbnails = this.appConfig.browseBy.showThumbnails; - } - } diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.ts b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.ts index 199bd3a748..38150d1a6a 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.ts +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.ts @@ -14,14 +14,4 @@ import { ItemSearchResultListElementComponent } from '../../../../../shared/obje */ export class JournalSearchResultListElementComponent extends ItemSearchResultListElementComponent { - /** - * Display thumbnails if required by configuration - */ - showThumbnails: boolean; - - ngOnInit(): void { - super.ngOnInit(); - this.showThumbnails = this.appConfig.browseBy.showThumbnails; - } - } diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.ts b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.ts index baa27cdf0c..a9edb530cb 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.ts +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.ts @@ -14,14 +14,4 @@ import { ItemSearchResultListElementComponent } from '../../../../../shared/obje */ export class OrgUnitSearchResultListElementComponent extends ItemSearchResultListElementComponent { - /** - * Display thumbnail if required by configuration - */ - showThumbnails: boolean; - - ngOnInit(): void { - super.ngOnInit(); - this.showThumbnails = this.appConfig.browseBy.showThumbnails; - } - } diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.ts b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.ts index 88e95528ac..aaf98a8091 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.ts +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.ts @@ -14,14 +14,4 @@ import { ItemSearchResultListElementComponent } from '../../../../../shared/obje */ export class ProjectSearchResultListElementComponent extends ItemSearchResultListElementComponent { - /** - * Display thumbnail if required by configuration - */ - showThumbnails: boolean; - - ngOnInit(): void { - super.ngOnInit(); - this.showThumbnails = this.appConfig.browseBy.showThumbnails; - } - } 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 5a2b5ba1f1..0a75394ddd 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 @@ -41,6 +41,11 @@ import { DsoSharedModule } from '../../dso-shared/dso-shared.module'; import { ItemCurateComponent } from './item-curate/item-curate.component'; import { ThemedItemStatusComponent } from './item-status/themed-item-status.component'; +import { ItemAccessControlComponent } from './item-access-control/item-access-control.component'; +import { ResultsBackButtonModule } from '../../shared/results-back-button/results-back-button.module'; +import { + AccessControlFormModule +} from '../../shared/access-control-form-container/access-control-form.module'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -57,6 +62,8 @@ import { ThemedItemStatusComponent } from './item-status/themed-item-status.comp NgbModule, ItemVersionsModule, DsoSharedModule, + ResultsBackButtonModule, + AccessControlFormModule, ], declarations: [ EditItemPageComponent, @@ -85,7 +92,8 @@ import { ThemedItemStatusComponent } from './item-status/themed-item-status.comp ItemAuthorizationsComponent, IdentifierDataComponent, ItemRegisterDoiComponent, - ItemCurateComponent + ItemCurateComponent, + ItemAccessControlComponent, ], 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 22eb624db7..f22027916f 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 @@ -42,6 +42,7 @@ import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metada import { ItemPageRegisterDoiGuard } from './item-page-register-doi.guard'; import { ItemCurateComponent } from './item-curate/item-curate.component'; import { ThemedItemStatusComponent } from './item-status/themed-item-status.component'; +import { ItemAccessControlComponent } from './item-access-control/item-access-control.component'; /** * Routing module that handles the routing for the Edit Item page administrator functionality @@ -112,6 +113,11 @@ import { ThemedItemStatusComponent } from './item-status/themed-item-status.comp data: { title: 'item.edit.tabs.versionhistory.title', showBreadcrumbs: true }, canActivate: [ItemPageVersionHistoryGuard] }, + { + path: 'access-control', + component: ItemAccessControlComponent, + data: { title: 'item.edit.tabs.access-control.title', showBreadcrumbs: true } + }, { path: 'mapper', component: ItemCollectionMapperComponent, diff --git a/src/app/item-page/edit-item-page/item-access-control/item-access-control.component.html b/src/app/item-page/edit-item-page/item-access-control/item-access-control.component.html new file mode 100644 index 0000000000..782e24fa2d --- /dev/null +++ b/src/app/item-page/edit-item-page/item-access-control/item-access-control.component.html @@ -0,0 +1,6 @@ + + diff --git a/src/app/item-page/edit-item-page/item-access-control/item-access-control.component.scss b/src/app/item-page/edit-item-page/item-access-control/item-access-control.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/edit-item-page/item-access-control/item-access-control.component.spec.ts b/src/app/item-page/edit-item-page/item-access-control/item-access-control.component.spec.ts new file mode 100644 index 0000000000..d841b97e2a --- /dev/null +++ b/src/app/item-page/edit-item-page/item-access-control/item-access-control.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ItemAccessControlComponent } from './item-access-control.component'; + +xdescribe('ItemAccessControlComponent', () => { + let component: ItemAccessControlComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ItemAccessControlComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemAccessControlComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/item-page/edit-item-page/item-access-control/item-access-control.component.ts b/src/app/item-page/edit-item-page/item-access-control/item-access-control.component.ts new file mode 100644 index 0000000000..874b624494 --- /dev/null +++ b/src/app/item-page/edit-item-page/item-access-control/item-access-control.component.ts @@ -0,0 +1,26 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; +import { ActivatedRoute } from '@angular/router'; + +@Component({ + selector: 'ds-item-access-control', + templateUrl: './item-access-control.component.html', + styleUrls: [ './item-access-control.component.scss' ], +}) +export class ItemAccessControlComponent implements OnInit { + + itemRD$: Observable>; + + constructor(private route: ActivatedRoute) {} + + ngOnInit(): void { + this.itemRD$ = this.route.parent.parent.data.pipe( + map((data) => data.dso) + ).pipe(getFirstSucceededRemoteData()) as Observable>; + } + +} diff --git a/src/app/menu.resolver.ts b/src/app/menu.resolver.ts index 3ec1df85fd..cad6a6ec57 100644 --- a/src/app/menu.resolver.ts +++ b/src/app/menu.resolver.ts @@ -659,6 +659,17 @@ export class MenuResolver implements Resolve { link: '/access-control/groups' } as LinkMenuItemModel, }, + { + id: 'access_control_bulk', + parentID: 'access_control', + active: false, + visible: isSiteAdmin, + model: { + type: MenuItemType.LINK, + text: 'menu.section.access_control_bulk', + link: '/access-control/bulk-access' + } as LinkMenuItemModel, + }, // TODO: enable this menu item once the feature has been implemented // { // id: 'access_control_authorizations', diff --git a/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.html b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.html new file mode 100644 index 0000000000..cd56904bd7 --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.html @@ -0,0 +1,111 @@ +
+
+ {{'access-control-no-access-conditions-warning-message' | translate}} +
+ + +
+ +
+
+ + + + {{'access-control-option-note' | translate}} + +
+ +
+ +
+ +
+ +
+
+ + {{'access-control-option-start-date-note' | translate}} + +
+ +
+ +
+ +
+ +
+
+ + {{'access-control-option-end-date-note' | translate}} + +
+
+ +
+ +
+ + +
+
+
+
+ + + +
diff --git a/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.scss b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.scss new file mode 100644 index 0000000000..43cc0789b2 --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.scss @@ -0,0 +1,7 @@ +.access-control-item { + display: grid; + grid-template-columns: 1fr 50px; + grid-gap: 10px; + padding-bottom: 15px; + border-bottom: 2px dotted grey; +} diff --git a/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.spec.ts b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.spec.ts new file mode 100644 index 0000000000..964eb30de2 --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.spec.ts @@ -0,0 +1,116 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AccessControlArrayFormComponent } from './access-control-array-form.component'; +import { FormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap'; +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ToDatePipe } from './to-date.pipe'; +import { SharedBrowseByModule } from '../../browse-by/shared-browse-by.module'; + +describe('AccessControlArrayFormComponent', () => { + let component: AccessControlArrayFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ CommonModule, FormsModule, SharedBrowseByModule, TranslateModule.forRoot(), NgbDatepickerModule ], + declarations: [ AccessControlArrayFormComponent, ToDatePipe ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AccessControlArrayFormComponent); + component = fixture.componentInstance; + component.dropdownOptions = [{name: 'Option1'}, {name: 'Option2'}] as any; + component.type = 'item'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have only one empty control access item in the form', () => { + const accessControlItems = fixture.debugElement.queryAll(By.css('.access-control-item')); + expect(accessControlItems.length).toEqual(1); + }); + + it('should add access control item', () => { + component.addAccessControlItem(); + expect(component.form.accessControls.length).toEqual(2); + }); + + it('should remove access control item', () => { + expect(component.form.accessControls.length).toEqual(1); + + component.addAccessControlItem(); + expect(component.form.accessControls.length).toEqual(2); + + const id = component.form.accessControls[0].id; + component.removeAccessControlItem(id); + expect(component.form.accessControls.length).toEqual(1); + }); + + it('should reset form value', () => { + const item = { itemName: 'item1', startDate: '2022-01-01', endDate: '2022-02-01' }; + component.addAccessControlItem(item.itemName); + + // set value to item1 + component.accessControlChanged( + component.form.accessControls[1], + 'item1' + ); + + component.reset(); + expect(component.form.accessControls[1]?.itemName).toEqual(undefined); + }); + + + it('should display a select dropdown with options', () => { + component.enable(); + fixture.detectChanges(); + + const id = component.form.accessControls[0].id; + + const selectElement: DebugElement = fixture.debugElement.query(By.css(`select#accesscontroloption-${id}`)); + expect(selectElement).toBeTruthy(); + + const options = selectElement.nativeElement.querySelectorAll('option'); + expect(options.length).toEqual(3); // 2 options + default empty option + + expect(options[0].value).toEqual(''); + expect(options[1].value).toEqual('Option1'); + expect(options[2].value).toEqual('Option2'); + }); + + it('should add new access control items when clicking "Add more" button', () => { + component.enable(); + fixture.detectChanges(); + + const addButton: DebugElement = fixture.debugElement.query(By.css(`button#add-btn-${component.type}`)); + addButton.nativeElement.click(); + fixture.detectChanges(); + + const accessControlItems = fixture.debugElement.queryAll(By.css('.access-control-item')); + expect(accessControlItems.length).toEqual(2); + }); + + it('should remove access control items when clicking remove button', () => { + component.enable(); + + component.addAccessControlItem('test'); + + fixture.detectChanges(); + + const removeButton: DebugElement[] = fixture.debugElement.queryAll(By.css('button.btn-outline-danger')); + removeButton[1].nativeElement.click(); + fixture.detectChanges(); + + const accessControlItems = fixture.debugElement.queryAll(By.css('.access-control-item')); + expect(accessControlItems.length).toEqual(1); + }); +}); diff --git a/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.ts b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.ts new file mode 100644 index 0000000000..227de596ff --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.ts @@ -0,0 +1,150 @@ +import {Component, Input, OnInit, ViewChild} from '@angular/core'; +import {NgForm} from '@angular/forms'; +import {AccessesConditionOption} from '../../../core/config/models/config-accesses-conditions-options.model'; +import {dateToISOFormat} from '../../date.util'; + +@Component({ + selector: 'ds-access-control-array-form', + templateUrl: './access-control-array-form.component.html', + styleUrls: ['./access-control-array-form.component.scss'], + exportAs: 'accessControlArrayForm' +}) +export class AccessControlArrayFormComponent implements OnInit { + @Input() dropdownOptions: AccessesConditionOption[] = []; + @Input() mode!: 'add' | 'replace'; + @Input() type!: 'item' | 'bitstream'; + + @ViewChild('ngForm', {static: true}) ngForm!: NgForm; + + form: { accessControls: AccessControlItem[] } = { + accessControls: [emptyAccessControlItem()] // Start with one empty access control item + }; + + formDisabled = true; + + ngOnInit(): void { + this.disable(); // Disable the form by default + } + + get allControlsAreEmpty() { + return this.form.accessControls + .every(x => x.itemName === null || x.itemName === ''); + } + + get showWarning() { + return this.mode === 'replace' && this.allControlsAreEmpty && !this.formDisabled; + } + + /** + * Add a new access control item to the form. + * Start and end date are disabled by default. + * @param itemName The name of the item to add + */ + addAccessControlItem(itemName: string = null) { + this.form.accessControls = [ + ...this.form.accessControls, + {...emptyAccessControlItem(), itemName} + ]; + } + + /** + * Remove an access control item from the form. + * @param ngModelGroup + * @param index + */ + removeAccessControlItem(id: number) { + this.form.accessControls = this.form.accessControls.filter(item => item.id !== id); + } + + /** + * Get the value of the form. + * This will be used to read the form value from the parent component. + * @return The form value + */ + getValue() { + return this.form.accessControls + .filter(x => x.itemName !== null && x.itemName !== '') + .map(x => ({ + name: x.itemName, + startDate: (x.startDate ? dateToISOFormat(x.startDate) : null), + endDate: (x.endDate ? dateToISOFormat(x.endDate) : null) + })); + } + + /** + * Set the value of the form from the parent component. + */ + reset() { + this.form.accessControls = []; + + // Add an empty access control item by default + this.addAccessControlItem(); + + this.disable(); + } + + /** + * Disable the form. + * This will be used to disable the form from the parent component. + */ + disable = () => { + this.ngForm.form.disable(); + this.formDisabled = true; + }; + + /** + * Enable the form. + * This will be used to enable the form from the parent component. + */ + enable = () => { + this.ngForm.form.enable(); + this.formDisabled = false; + }; + + accessControlChanged(control: AccessControlItem, selectedItem: string) { + const item = this.dropdownOptions + .find((x) => x.name === selectedItem); + + control.startDate = null; + control.endDate = null; + + control.hasStartDate = item?.hasStartDate || false; + control.hasEndDate = item?.hasEndDate || false; + + control.maxStartDate = item?.maxStartDate || null; + control.maxEndDate = item?.maxEndDate || null; + } + + trackById(index: number, item: AccessControlItem) { + return item.id; + } + +} + + +export interface AccessControlItem { + id: number; // will be used only locally + + itemName: string | null; + + hasStartDate?: boolean; + startDate: string | null; + maxStartDate?: string | null; + + hasEndDate?: boolean; + endDate: string | null; + maxEndDate?: string | null; +} + +const emptyAccessControlItem = (): AccessControlItem => ({ + id: randomID(), + itemName: null, + startDate: null, + hasStartDate: false, + maxStartDate: null, + endDate: null, + hasEndDate: false, + maxEndDate: null, +}); + +const randomID = () => Math.floor(Math.random() * 1000000); diff --git a/src/app/shared/access-control-form-container/access-control-array-form/to-date.pipe.ts b/src/app/shared/access-control-form-container/access-control-array-form/to-date.pipe.ts new file mode 100644 index 0000000000..203d12a59d --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-array-form/to-date.pipe.ts @@ -0,0 +1,23 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {NgbDateStruct} from '@ng-bootstrap/ng-bootstrap/datepicker/ngb-date-struct'; + +@Pipe({ + // eslint-disable-next-line @angular-eslint/pipe-prefix + name: 'toDate', + pure: false +}) +export class ToDatePipe implements PipeTransform { + transform(dateValue: string | null): NgbDateStruct | null { + if (!dateValue) { + return null; + } + + const date = new Date(dateValue); + return { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate() + } as NgbDateStruct; + } + +} diff --git a/src/app/shared/access-control-form-container/access-control-form-container-intial-state.ts b/src/app/shared/access-control-form-container/access-control-form-container-intial-state.ts new file mode 100644 index 0000000000..b5081db6c2 --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-form-container-intial-state.ts @@ -0,0 +1,27 @@ +import {ListableObject} from '../object-collection/shared/listable-object.model'; + +export const createAccessControlInitialFormState = (): AccessControlFormState => ({ + item: { + toggleStatus: false, + accessMode: 'replace', + }, + bitstream: { + toggleStatus: false, + accessMode: 'replace', + changesLimit: 'all', // 'all' | 'selected' + selectedBitstreams: [] as ListableObject[], + }, +}); + +export interface AccessControlFormState { + item: { + toggleStatus: boolean, + accessMode: 'add' | 'replace', + }, + bitstream: { + toggleStatus: boolean, + accessMode: 'add' | 'replace', + changesLimit: string, + selectedBitstreams: ListableObject[], + } +} diff --git a/src/app/shared/access-control-form-container/access-control-form-container.component.html b/src/app/shared/access-control-form-container/access-control-form-container.component.html new file mode 100644 index 0000000000..a5173d10d7 --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-form-container.component.html @@ -0,0 +1,167 @@ +
+
+
+ + + +
+
+ +
+
+

+ {{ 'access-control-item-header-toggle' | translate }} +

+ + +
+ +
+
+ {{ 'access-control-mode' | translate }} +
+
+
+ + +
+
+ + +
+
+
+
+ +
+
{{'access-control-access-conditions' | translate}}
+ + + +
+ +
+ +
+ +
+
+

+ {{'access-control-bitstream-header-toggle' | translate}} +

+ + +
+ +
+
+ {{'access-control-limit-to-specific' | translate}} +
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+ {{'access-control-mode' | translate}} +
+
+
+ + +
+
+ + +
+
+
+ +
+
{{'access-control-access-conditions' | translate}}
+ + + +
+
+
+ +
+ +
+ + +
+
+
+
diff --git a/src/app/shared/access-control-form-container/access-control-form-container.component.scss b/src/app/shared/access-control-form-container/access-control-form-container.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/access-control-form-container/access-control-form-container.component.spec.ts b/src/app/shared/access-control-form-container/access-control-form-container.component.spec.ts new file mode 100644 index 0000000000..4d02f7a52d --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-form-container.component.spec.ts @@ -0,0 +1,149 @@ +import {ComponentFixture, fakeAsync, TestBed} from '@angular/core/testing'; +import {NgbDatepickerModule, NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; +import {Component} from '@angular/core'; +import {of} from 'rxjs'; +import {AccessControlFormContainerComponent} from './access-control-form-container.component'; +import {BulkAccessControlService} from './bulk-access-control.service'; +import {BulkAccessConfigDataService} from '../../core/config/bulk-access-config-data.service'; +import {Item} from '../../core/shared/item.model'; +import {SelectableListService} from '../object-list/selectable-list/selectable-list.service'; +import {createAccessControlInitialFormState} from './access-control-form-container-intial-state'; +import {CommonModule} from '@angular/common'; +import {SharedBrowseByModule} from '../browse-by/shared-browse-by.module'; +import {TranslateModule} from '@ngx-translate/core'; +import {FormsModule} from '@angular/forms'; +import {UiSwitchModule} from 'ngx-ui-switch'; +import { + ITEM_ACCESS_CONTROL_SELECT_BITSTREAMS_LIST_ID +} from './item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component'; +import {AccessControlFormModule} from './access-control-form.module'; + + +describe('AccessControlFormContainerComponent', () => { + let component: AccessControlFormContainerComponent; + let fixture: ComponentFixture>; + + +// Mock NgbModal + @Component({selector: 'ds-ngb-modal', template: ''}) + class MockNgbModalComponent { + } + +// Mock dependencies + const mockBulkAccessControlService = { + createPayloadFile: jasmine.createSpy('createPayloadFile').and.returnValue({file: 'mocked-file'}), + executeScript: jasmine.createSpy('executeScript').and.returnValue(of('success')), + }; + + const mockBulkAccessConfigDataService = { + findByName: jasmine.createSpy('findByName').and.returnValue(of({payload: {options: []}})), + }; + + const mockSelectableListService = { + getSelectableList: jasmine.createSpy('getSelectableList').and.returnValue(of({selection: []})), + deselectAll: jasmine.createSpy('deselectAll'), + }; + + const mockNgbModal = { + open: jasmine.createSpy('open').and.returnValue( + { componentInstance: {}, closed: of({})} as NgbModalRef + ) + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [AccessControlFormContainerComponent, MockNgbModalComponent], + imports: [ + CommonModule, + FormsModule, + SharedBrowseByModule, + AccessControlFormModule, + TranslateModule.forRoot(), + NgbDatepickerModule, + UiSwitchModule + ], + providers: [ + {provide: BulkAccessControlService, useValue: mockBulkAccessControlService}, + {provide: BulkAccessConfigDataService, useValue: mockBulkAccessConfigDataService}, + {provide: SelectableListService, useValue: mockSelectableListService}, + {provide: NgbModal, useValue: mockNgbModal}, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AccessControlFormContainerComponent); + component = fixture.componentInstance; + component.state = createAccessControlInitialFormState(); + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should reset the form', fakeAsync(() => { + fixture.detectChanges(); + const resetSpy = spyOn(component.bitstreamAccessCmp, 'reset'); + spyOn(component.itemAccessCmp, 'reset'); + + component.reset(); + + expect(resetSpy).toHaveBeenCalled(); + expect(component.itemAccessCmp.reset).toHaveBeenCalled(); + expect(component.state).toEqual(createAccessControlInitialFormState()); + })); + + it('should submit the form', () => { + const bitstreamAccess = 'bitstreamAccess'; + const itemAccess = 'itemAccess'; + component.bitstreamAccessCmp.getValue = jasmine.createSpy('getValue').and.returnValue(bitstreamAccess); + component.itemAccessCmp.getValue = jasmine.createSpy('getValue').and.returnValue(itemAccess); + component.itemRD = {payload: {uuid: 'item-uuid'}} as any; + + component.submit(); + + expect(mockBulkAccessControlService.createPayloadFile).toHaveBeenCalledWith({ + bitstreamAccess, + itemAccess, + state: createAccessControlInitialFormState(), + }); + expect(mockBulkAccessControlService.executeScript).toHaveBeenCalledWith(['item-uuid'], 'mocked-file'); + }); + + it('should handle the status change for bitstream access', () => { + component.bitstreamAccessCmp.enable = jasmine.createSpy('enable'); + component.bitstreamAccessCmp.disable = jasmine.createSpy('disable'); + + component.handleStatusChange('bitstream', true); + expect(component.bitstreamAccessCmp.enable).toHaveBeenCalled(); + + component.handleStatusChange('bitstream', false); + expect(component.bitstreamAccessCmp.disable).toHaveBeenCalled(); + }); + + it('should handle the status change for item access', () => { + component.itemAccessCmp.enable = jasmine.createSpy('enable'); + component.itemAccessCmp.disable = jasmine.createSpy('disable'); + + component.handleStatusChange('item', true); + expect(component.itemAccessCmp.enable).toHaveBeenCalled(); + + component.handleStatusChange('item', false); + expect(component.itemAccessCmp.disable).toHaveBeenCalled(); + }); + + it('should open the select bitstreams modal', () => { + const modalService = TestBed.inject(NgbModal); + + component.openSelectBitstreamsModal(new Item()); + expect(modalService.open).toHaveBeenCalled(); + }); + + it('should unsubscribe and deselect all on component destroy', () => { + component.ngOnDestroy(); + expect(component.selectableListService.deselectAll).toHaveBeenCalledWith( + ITEM_ACCESS_CONTROL_SELECT_BITSTREAMS_LIST_ID + ); + }); +}); diff --git a/src/app/shared/access-control-form-container/access-control-form-container.component.ts b/src/app/shared/access-control-form-container/access-control-form-container.component.ts new file mode 100644 index 0000000000..69a598f7ce --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-form-container.component.ts @@ -0,0 +1,160 @@ +import { ChangeDetectorRef, Component, Input, OnDestroy, ViewChild } from '@angular/core'; +import { concatMap, Observable, shareReplay } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { Item } from '../../core/shared/item.model'; +import { AccessControlArrayFormComponent } from './access-control-array-form/access-control-array-form.component'; +import { BulkAccessControlService } from './bulk-access-control.service'; +import { SelectableListService } from '../object-list/selectable-list/selectable-list.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { map, take } from 'rxjs/operators'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { + ITEM_ACCESS_CONTROL_SELECT_BITSTREAMS_LIST_ID, + ItemAccessControlSelectBitstreamsModalComponent +} from './item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component'; +import { BulkAccessConfigDataService } from '../../core/config/bulk-access-config-data.service'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { BulkAccessConditionOptions } from '../../core/config/models/bulk-access-condition-options.model'; +import { AlertType } from '../alert/aletr-type'; +import { + createAccessControlInitialFormState +} from './access-control-form-container-intial-state'; + +@Component({ + selector: 'ds-access-control-form-container', + templateUrl: './access-control-form-container.component.html', + styleUrls: [ './access-control-form-container.component.scss' ], + exportAs: 'dsAccessControlForm' +}) +export class AccessControlFormContainerComponent implements OnDestroy { + + /** + * Will be used to determine if we need to show the limit changes to specific bitstreams radio buttons + */ + @Input() showLimitToSpecificBitstreams = false; + + /** + * The title message of the access control form (translate key) + */ + @Input() titleMessage = ''; + + /** + * The item to which the access control form applies + */ + @Input() itemRD: RemoteData; + + /** + * Whether to show the submit and cancel button + * We want to hide these buttons when the form is + * used in an accordion, and we want to show buttons somewhere else + */ + @Input() showSubmit = true; + + @ViewChild('bitstreamAccessCmp', { static: true }) bitstreamAccessCmp: AccessControlArrayFormComponent; + @ViewChild('itemAccessCmp', { static: true }) itemAccessCmp: AccessControlArrayFormComponent; + + readonly AlertType = AlertType; + + constructor( + private bulkAccessConfigService: BulkAccessConfigDataService, + private bulkAccessControlService: BulkAccessControlService, + public selectableListService: SelectableListService, + protected modalService: NgbModal, + private cdr: ChangeDetectorRef + ) {} + + state = createAccessControlInitialFormState(); + + dropdownData$: Observable = this.bulkAccessConfigService.findByName('default').pipe( + getFirstCompletedRemoteData(), + map((configRD: RemoteData) => configRD.hasSucceeded ? configRD.payload : null), + shareReplay(1) + ); + + /** + * Will be used from a parent component to read the value of the form + */ + getFormValue() { + console.log({ + bitstream: this.bitstreamAccessCmp.getValue(), + item: this.itemAccessCmp.getValue(), + state: this.state + }); + return { + bitstream: this.bitstreamAccessCmp.getValue(), + item: this.itemAccessCmp.getValue(), + state: this.state + }; + } + + /** + * Reset the form to its initial state + * This will also reset the state of the child components (bitstream and item access) + */ + reset() { + this.bitstreamAccessCmp.reset(); + this.itemAccessCmp.reset(); + this.state = createAccessControlInitialFormState(); + } + + /** + * Submit the form + * This will create a payload file and execute the script + */ + submit() { + const bitstreamAccess = this.bitstreamAccessCmp.getValue(); + const itemAccess = this.itemAccessCmp.getValue(); + + const { file } = this.bulkAccessControlService.createPayloadFile({ + bitstreamAccess, + itemAccess, + state: this.state + }); + + this.bulkAccessControlService.executeScript( + [ this.itemRD.payload.uuid ], + file + ).pipe(take(1)).subscribe((res) => { + console.log('success', res); + }); + } + + /** + * Handle the status change of the access control form (item or bitstream) + * This will enable/disable the access control form for the item or bitstream + * @param type The type of the access control form (item or bitstream) + * @param active boolean indicating whether the access control form should be enabled or disabled + */ + handleStatusChange(type: 'item' | 'bitstream', active: boolean) { + if (type === 'bitstream') { + active ? this.bitstreamAccessCmp.enable() : this.bitstreamAccessCmp.disable(); + } else if (type === 'item') { + active ? this.itemAccessCmp.enable() : this.itemAccessCmp.disable(); + } + } + + /** + * Open the modal to select bitstreams for which to change the access control + * This will open the modal and pass the currently selected bitstreams + * @param item The item for which to change the access control + */ + openSelectBitstreamsModal(item: Item) { + const ref = this.modalService.open(ItemAccessControlSelectBitstreamsModalComponent); + ref.componentInstance.selectedBitstreams = this.state.bitstream.selectedBitstreams; + ref.componentInstance.item = item; + + ref.closed.pipe( + concatMap(() => this.selectableListService.getSelectableList(ITEM_ACCESS_CONTROL_SELECT_BITSTREAMS_LIST_ID)), + take(1) + ).subscribe((list) => { + this.state.bitstream.selectedBitstreams = list?.selection || []; + this.cdr.detectChanges(); + }); + } + + ngOnDestroy(): void { + this.selectableListService.deselectAll(ITEM_ACCESS_CONTROL_SELECT_BITSTREAMS_LIST_ID); + } + +} + diff --git a/src/app/shared/access-control-form-container/access-control-form.module.ts b/src/app/shared/access-control-form-container/access-control-form.module.ts new file mode 100644 index 0000000000..3bbdb3ab5d --- /dev/null +++ b/src/app/shared/access-control-form-container/access-control-form.module.ts @@ -0,0 +1,32 @@ +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; + +import {TranslateModule} from '@ngx-translate/core'; +import {UiSwitchModule} from 'ngx-ui-switch'; + +import {AccessControlArrayFormComponent} from './access-control-array-form/access-control-array-form.component'; +import {SharedModule} from '../shared.module'; +import { + ItemAccessControlSelectBitstreamsModalComponent +} from './item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component'; +import {AccessControlFormContainerComponent} from './access-control-form-container.component'; +import {NgbDatepickerModule} from '@ng-bootstrap/ng-bootstrap'; +import {ToDatePipe} from './access-control-array-form/to-date.pipe'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + TranslateModule, + UiSwitchModule, + NgbDatepickerModule + ], + declarations: [ + AccessControlFormContainerComponent, + AccessControlArrayFormComponent, + ItemAccessControlSelectBitstreamsModalComponent, + ToDatePipe + ], + exports: [ AccessControlFormContainerComponent, AccessControlArrayFormComponent ], +}) +export class AccessControlFormModule {} diff --git a/src/app/shared/access-control-form-container/bulk-access-control.service.spec.ts b/src/app/shared/access-control-form-container/bulk-access-control.service.spec.ts new file mode 100644 index 0000000000..16d05edeb7 --- /dev/null +++ b/src/app/shared/access-control-form-container/bulk-access-control.service.spec.ts @@ -0,0 +1,94 @@ +import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import { BulkAccessControlService } from './bulk-access-control.service'; +import { ScriptDataService } from '../../core/data/processes/script-data.service'; +import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationsServiceStub } from '../testing/notifications-service.stub'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { Process } from '../../process-page/processes/process.model'; + +describe('BulkAccessControlService', () => { + let service: BulkAccessControlService; + let scriptServiceSpy: jasmine.SpyObj; + + const mockPayload: any = { + 'bitstream': [], + 'item': [ + { + 'name': 'embargo', + 'startDate': { + 'year': 2026, + 'month': 5, + 'day': 31 + }, + 'endDate': null + } + ], + 'state': { + 'item': { + 'toggleStatus': true, + 'accessMode': 'replace' + }, + 'bitstream': { + 'toggleStatus': false, + 'accessMode': '', + 'changesLimit': '', + 'selectedBitstreams': [] + } + } + }; + + beforeEach(() => { + const spy = jasmine.createSpyObj('ScriptDataService', ['invoke']); + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + TranslateModule.forRoot() + ], + providers: [ + BulkAccessControlService, + { provide: ScriptDataService, useValue: spy }, + { provide: NotificationsService, useValue: NotificationsServiceStub }, + ] + }); + service = TestBed.inject(BulkAccessControlService); + scriptServiceSpy = TestBed.inject(ScriptDataService) as jasmine.SpyObj; + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('createPayloadFile', () => { + it('should create a file and return the URL and file object', () => { + const payload = mockPayload; + const result = service.createPayloadFile(payload); + + expect(result.url).toBeTruthy(); + expect(result.file).toBeTruthy(); + }); + }); + + describe('executeScript', () => { + it('should invoke the script service with the correct parameters', () => { + const uuids = ['123', '456']; + const file = new File(['test'], 'data.json', { type: 'application/json' }); + const expectedParams: ProcessParameter[] = [ + { name: '-f', value: 'data.json' }, + { name: '-u', value: '123' }, + { name: '-u', value: '456' }, + ]; + + // @ts-ignore + scriptServiceSpy.invoke.and.returnValue(createSuccessfulRemoteDataObject$(new Process())); + + const result = service.executeScript(uuids, file); + + expect(scriptServiceSpy.invoke).toHaveBeenCalledWith('bulk-access-control', expectedParams, [file]); + expect(result).toBeTruthy(); + }); + }); +}); diff --git a/src/app/shared/access-control-form-container/bulk-access-control.service.ts b/src/app/shared/access-control-form-container/bulk-access-control.service.ts new file mode 100644 index 0000000000..6fb6b62532 --- /dev/null +++ b/src/app/shared/access-control-form-container/bulk-access-control.service.ts @@ -0,0 +1,146 @@ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; + +import { ScriptDataService } from '../../core/data/processes/script-data.service'; +import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; +import { AccessControlFormState } from './access-control-form-container-intial-state'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { Process } from '../../process-page/processes/process.model'; +import { isNotEmpty } from '../empty.util'; +import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths'; +import { NotificationsService } from '../notifications/notifications.service'; + +export interface BulkAccessPayload { + state: AccessControlFormState; + bitstreamAccess: any; + itemAccess: any; +} + +/** + * This service is used to create a payload file and execute the bulk access control script + */ +@Injectable({ providedIn: 'root' }) +export class BulkAccessControlService { + constructor( + private notificationsService: NotificationsService, + private router: Router, + private scriptService: ScriptDataService, + private translationService: TranslateService + ) {} + + /** + * Create a payload file from the given payload and return the file and the url to the file + * The created file will be used as input for the bulk access control script + * @param payload The payload to create the file from + */ + createPayloadFile(payload: BulkAccessPayload) { + const content = convertToBulkAccessControlFileModel(payload); + + const blob = new Blob([JSON.stringify(content, null, 2)], { + type: 'application/json', + }); + + const file = new File([blob], 'data.json', { + type: 'application/json', + }); + + const url = URL.createObjectURL(file); + + return { url, file }; + } + + /** + * Execute the bulk access control script with the given uuids and file + * @param uuids + * @param file + */ + executeScript(uuids: string[], file: File): Observable { + console.log('execute', { uuids, file }); + + const params: ProcessParameter[] = [ + { name: '-f', value: file.name } + ]; + uuids.forEach((uuid) => { + params.push({ name: '-u', value: uuid }); + }); + + return this.scriptService.invoke('bulk-access-control', params, [file]).pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + if (rd.hasSucceeded) { + const title = this.translationService.get('process.new.notification.success.title'); + const content = this.translationService.get('process.new.notification.success.content'); + this.notificationsService.success(title, content); + if (isNotEmpty(rd.payload)) { + this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); + } + return true; + } else { + const title = this.translationService.get('process.new.notification.error.title'); + const content = this.translationService.get('process.new.notification.error.content'); + this.notificationsService.error(title, content); + return false; + } + }) + ); + } +} + +/** + * Convert the given payload to a BulkAccessControlFileModel + * @param payload + */ +export const convertToBulkAccessControlFileModel = (payload: { state: AccessControlFormState, bitstreamAccess: AccessCondition[], itemAccess: AccessCondition[] }): BulkAccessControlFileModel => { + let finalPayload: BulkAccessControlFileModel = {}; + + const itemEnabled = payload.state.item.toggleStatus; + const bitstreamEnabled = payload.state.bitstream.toggleStatus; + + if (itemEnabled) { + finalPayload.item = { + mode: payload.state.item.accessMode, + accessConditions: payload.itemAccess + }; + } + + if (bitstreamEnabled) { + const constraints = { uuid: [] }; + + if (bitstreamEnabled && payload.state.bitstream.changesLimit === 'selected') { + // @ts-ignore + constraints.uuid = payload.state.bitstream.selectedBitstreams.map((x) => x.id); + } + + finalPayload.bitstream = { + constraints, + mode: payload.state.bitstream.accessMode, + accessConditions: payload.bitstreamAccess + }; + } + + return finalPayload; +}; + + +export interface BulkAccessControlFileModel { + item?: { + mode: string; + accessConditions: AccessCondition[]; + }, + bitstream?: { + constraints: { uuid: string[] }; + mode: string; + accessConditions: AccessCondition[]; + } +} + +interface AccessCondition { + name: string; + startDate?: string; + endDate?: string; +} diff --git a/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.html b/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.html new file mode 100644 index 0000000000..8cf0ecea38 --- /dev/null +++ b/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.html @@ -0,0 +1,35 @@ + + + diff --git a/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.scss b/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.spec.ts b/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.spec.ts new file mode 100644 index 0000000000..f60d9a70e7 --- /dev/null +++ b/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ItemAccessControlSelectBitstreamsModalComponent } from './item-access-control-select-bitstreams-modal.component'; + +xdescribe('ItemAccessControlSelectBitstreamsModalComponent', () => { + let component: ItemAccessControlSelectBitstreamsModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ItemAccessControlSelectBitstreamsModalComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemAccessControlSelectBitstreamsModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.ts b/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.ts new file mode 100644 index 0000000000..617803a0c4 --- /dev/null +++ b/src/app/shared/access-control-form-container/item-access-control-select-bitstreams-modal/item-access-control-select-bitstreams-modal.component.ts @@ -0,0 +1,62 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { BehaviorSubject } from 'rxjs'; +import { PaginatedList } from 'src/app/core/data/paginated-list.model'; +import { RemoteData } from 'src/app/core/data/remote-data'; +import { Bitstream } from 'src/app/core/shared/bitstream.model'; +import { Context } from 'src/app/core/shared/context.model'; +import { PaginationComponentOptions } from '../../pagination/pagination-component-options.model'; +import { Item } from '../../../core/shared/item.model'; +import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { TranslateService } from '@ngx-translate/core'; +import { hasValue } from '../../empty.util'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; + +export const ITEM_ACCESS_CONTROL_SELECT_BITSTREAMS_LIST_ID = 'item-access-control-select-bitstreams'; + +@Component({ + selector: 'ds-item-access-control-select-bitstreams-modal', + templateUrl: './item-access-control-select-bitstreams-modal.component.html', + styleUrls: [ './item-access-control-select-bitstreams-modal.component.scss' ] +}) +export class ItemAccessControlSelectBitstreamsModalComponent implements OnInit { + + LIST_ID = ITEM_ACCESS_CONTROL_SELECT_BITSTREAMS_LIST_ID; + + @Input() item!: Item; + @Input() selectedBitstreams: string[] = []; + + data$ = new BehaviorSubject> | null>(null); + paginationConfig: PaginationComponentOptions; + pageSize = 5; + + context: Context = Context.Bitstream; + + constructor( + private bitstreamService: BitstreamDataService, + protected paginationService: PaginationService, + protected translateService: TranslateService, + public activeModal: NgbActiveModal + ) { } + + ngOnInit() { + this.loadForPage(1); + + this.paginationConfig = new PaginationComponentOptions(); + this.paginationConfig.id = 'iacsbm'; + this.paginationConfig.currentPage = 1; + if (hasValue(this.pageSize)) { + this.paginationConfig.pageSize = this.pageSize; + } + } + + loadForPage(page: number) { + this.bitstreamService.findAllByItemAndBundleName(this.item, 'ORIGINAL', { currentPage: page}, false) + .pipe( + getFirstCompletedRemoteData(), + ) + .subscribe(this.data$); + } + +} diff --git a/src/app/shared/object-collection/object-collection.component.html b/src/app/shared/object-collection/object-collection.component.html index 3dafba8298..3b0dca80ac 100644 --- a/src/app/shared/object-collection/object-collection.component.html +++ b/src/app/shared/object-collection/object-collection.component.html @@ -8,6 +8,7 @@ [context]="context" [hidePaginationDetail]="hidePaginationDetail" [showPaginator]="showPaginator" + [showThumbnails]="showThumbnails" (paginationChange)="onPaginationChange($event)" (pageChange)="onPageChange($event)" (pageSizeChange)="onPageSizeChange($event)" @@ -34,6 +35,7 @@ [context]="context" [hidePaginationDetail]="hidePaginationDetail" [showPaginator]="showPaginator" + [showThumbnails]="showThumbnails" (paginationChange)="onPaginationChange($event)" (pageChange)="onPageChange($event)" (pageSizeChange)="onPageSizeChange($event)" @@ -50,6 +52,7 @@ [context]="context" [hidePaginationDetail]="hidePaginationDetail" [showPaginator]="showPaginator" + [showThumbnails]="showThumbnails" (contentChange)="contentChange.emit($event)" *ngIf="(currentMode$ | async) === viewModeEnum.DetailedListElement"> diff --git a/src/app/shared/object-collection/object-collection.component.ts b/src/app/shared/object-collection/object-collection.component.ts index 369192c488..e1f9182562 100644 --- a/src/app/shared/object-collection/object-collection.component.ts +++ b/src/app/shared/object-collection/object-collection.component.ts @@ -107,6 +107,11 @@ export class ObjectCollectionComponent implements OnInit { */ @Input() showPaginator = true; + /** + * Whether to show the thumbnail preview + */ + @Input() showThumbnails; + /** * the page info of the list */ diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts index 6d2307a7f0..7a3cc42bf5 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts @@ -70,6 +70,11 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnChanges */ @Input() showLabel = true; + /** + * Whether to show the thumbnail preview + */ + @Input() showThumbnails; + /** * The value to display for this element */ @@ -105,6 +110,7 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnChanges 'linkType', 'listID', 'showLabel', + 'showThumbnails', 'context', 'viewMode', 'value', diff --git a/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts b/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts index 6b9aff069f..4614adcf43 100644 --- a/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts +++ b/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts @@ -42,6 +42,11 @@ export class AbstractListableElementComponent { */ @Input() showLabel = true; + /** + * Whether to show the thumbnail preview + */ + @Input() showThumbnails; + /** * The context we matched on to get this component */ diff --git a/src/app/shared/object-detail/object-detail.component.html b/src/app/shared/object-detail/object-detail.component.html index 05b8342ca3..d077e2fd2b 100644 --- a/src/app/shared/object-detail/object-detail.component.html +++ b/src/app/shared/object-detail/object-detail.component.html @@ -21,6 +21,7 @@ diff --git a/src/app/shared/object-detail/object-detail.component.ts b/src/app/shared/object-detail/object-detail.component.ts index 15bd5b7bca..1a32be74b8 100644 --- a/src/app/shared/object-detail/object-detail.component.ts +++ b/src/app/shared/object-detail/object-detail.component.ts @@ -64,6 +64,11 @@ export class ObjectDetailComponent { */ @Input() showPaginator = true; + /** + * Whether to show the thumbnail preview + */ + @Input() showThumbnails; + /** * Emit when one of the listed object has changed. */ diff --git a/src/app/shared/object-grid/object-grid.component.html b/src/app/shared/object-grid/object-grid.component.html index 4050f93f77..59fe18820d 100644 --- a/src/app/shared/object-grid/object-grid.component.html +++ b/src/app/shared/object-grid/object-grid.component.html @@ -19,7 +19,11 @@
- +
diff --git a/src/app/shared/object-grid/object-grid.component.ts b/src/app/shared/object-grid/object-grid.component.ts index 1b5ab075e2..91630ca007 100644 --- a/src/app/shared/object-grid/object-grid.component.ts +++ b/src/app/shared/object-grid/object-grid.component.ts @@ -1,11 +1,12 @@ -import { combineLatest as observableCombineLatest, BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { startWith, distinctUntilChanged, map } from 'rxjs/operators'; +import { distinctUntilChanged, map, startWith } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, EventEmitter, - Input, OnInit, + Input, + OnInit, Output, ViewEncapsulation } from '@angular/core'; @@ -54,6 +55,11 @@ export class ObjectGridComponent implements OnInit { */ @Input() showPaginator = true; + /** + * Whether to show the thumbnail preview + */ + @Input() showThumbnails; + /** * The whether or not the gear is hidden */ diff --git a/src/app/shared/object-list/bitstream-list-item/bitstream-list-item.component.html b/src/app/shared/object-list/bitstream-list-item/bitstream-list-item.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/object-list/bitstream-list-item/bitstream-list-item.component.scss b/src/app/shared/object-list/bitstream-list-item/bitstream-list-item.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/object-list/bitstream-list-item/bitstream-list-item.component.spec.ts b/src/app/shared/object-list/bitstream-list-item/bitstream-list-item.component.spec.ts new file mode 100644 index 0000000000..906add6578 --- /dev/null +++ b/src/app/shared/object-list/bitstream-list-item/bitstream-list-item.component.spec.ts @@ -0,0 +1,35 @@ +import {CommonModule} from '@angular/common'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {RouterTestingModule} from '@angular/router/testing'; +import {TranslateModule} from '@ngx-translate/core'; +import {SharedModule} from '../../shared.module'; + +import {BitstreamListItemComponent} from './bitstream-list-item.component'; +import {DSONameService} from '../../../core/breadcrumbs/dso-name.service'; +import {DSONameServiceMock} from '../../mocks/dso-name.service.mock'; + +describe('BitstreamListItemComponent', () => { + let component: BitstreamListItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ BitstreamListItemComponent ], + imports: [ CommonModule, SharedModule, TranslateModule, RouterTestingModule ], + providers: [{ provide: DSONameService, useValue: new DSONameServiceMock() }] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamListItemComponent); + component = fixture.componentInstance; + // @ts-ignore + component.object = {name: 'test'}; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/object-list/bitstream-list-item/bitstream-list-item.component.ts b/src/app/shared/object-list/bitstream-list-item/bitstream-list-item.component.ts new file mode 100644 index 0000000000..f281c0bd77 --- /dev/null +++ b/src/app/shared/object-list/bitstream-list-item/bitstream-list-item.component.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; +import { listableObjectComponent } from '../../object-collection/shared/listable-object/listable-object.decorator'; +import { ViewMode } from '../../../core/shared/view-mode.model'; +import { + AbstractListableElementComponent +} from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { Context } from '../../../core/shared/context.model'; + + +@listableObjectComponent(Bitstream, ViewMode.ListElement, Context.Bitstream) +@Component({ + selector: 'ds-bitstream-list-item', + template: ` {{object.name}} `, + styleUrls: ['./bitstream-list-item.component.scss'] +}) +export class BitstreamListItemComponent extends AbstractListableElementComponent{} diff --git a/src/app/shared/object-list/object-list.component.ts b/src/app/shared/object-list/object-list.component.ts index 65e2b508da..5161b75459 100644 --- a/src/app/shared/object-list/object-list.component.ts +++ b/src/app/shared/object-list/object-list.component.ts @@ -81,6 +81,11 @@ export class ObjectListComponent { */ @Input() showPaginator = true; + /** + * Whether to show the thumbnail preview + */ + @Input() showThumbnails; + /** * Emit when one of the listed object has changed. */ diff --git a/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.ts index 60415f649e..23e9e5ee57 100644 --- a/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.ts @@ -23,7 +23,7 @@ export class CollectionSearchResultListElementComponent extends SearchResultList ngOnInit(): void { super.ngOnInit(); - this.showThumbnails = this.appConfig.browseBy.showThumbnails; + this.showThumbnails = this.showThumbnails ?? this.appConfig.browseBy.showThumbnails; } } diff --git a/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.ts index 4cc25b8b76..f31e36ce82 100644 --- a/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.ts @@ -23,6 +23,6 @@ export class CommunitySearchResultListElementComponent extends SearchResultListE ngOnInit(): void { super.ngOnInit(); - this.showThumbnails = this.appConfig.browseBy.showThumbnails; + this.showThumbnails = this.showThumbnails ?? this.appConfig.browseBy.showThumbnails; } } diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts index f84ae642ad..99251d3c24 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.ts @@ -22,14 +22,9 @@ export class ItemSearchResultListElementComponent extends SearchResultListElemen */ itemPageRoute: string; - /** - * Display thumbnails if required by configuration - */ - showThumbnails: boolean; - ngOnInit(): void { super.ngOnInit(); - this.showThumbnails = this.appConfig.browseBy.showThumbnails; + this.showThumbnails = this.showThumbnails ?? this.appConfig.browseBy.showThumbnails; this.itemPageRoute = getItemPageRoute(this.dso); } } diff --git a/src/app/shared/object-list/selectable-list/selectable-list.service.spec.ts b/src/app/shared/object-list/selectable-list/selectable-list.service.spec.ts index 1535671f79..496ef28761 100644 --- a/src/app/shared/object-list/selectable-list/selectable-list.service.spec.ts +++ b/src/app/shared/object-list/selectable-list/selectable-list.service.spec.ts @@ -11,7 +11,7 @@ import { } from './selectable-list.actions'; import { AppState } from '../../../app.reducer'; -class SelectableObject extends ListableObject { +export class SelectableObject extends ListableObject { constructor(private value: string) { super(); } diff --git a/src/app/shared/object-list/themed-object-list.component.ts b/src/app/shared/object-list/themed-object-list.component.ts index 913302351f..14ddf474ad 100644 --- a/src/app/shared/object-list/themed-object-list.component.ts +++ b/src/app/shared/object-list/themed-object-list.component.ts @@ -1,13 +1,13 @@ -import {Component, EventEmitter, Input, Output} from '@angular/core'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; import { ObjectListComponent } from './object-list.component'; import { ThemedComponent } from '../theme-support/themed.component'; -import {PaginationComponentOptions} from '../pagination/pagination-component-options.model'; -import {SortDirection, SortOptions} from '../../core/cache/models/sort-options.model'; -import {CollectionElementLinkType} from '../object-collection/collection-element-link.type'; -import {Context} from '../../core/shared/context.model'; -import {RemoteData} from '../../core/data/remote-data'; -import {PaginatedList} from '../../core/data/paginated-list.model'; -import {ListableObject} from '../object-collection/shared/listable-object.model'; +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { CollectionElementLinkType } from '../object-collection/collection-element-link.type'; +import { Context } from '../../core/shared/context.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { ListableObject } from '../object-collection/shared/listable-object.model'; /** * Themed wrapper for ObjectListComponent @@ -78,6 +78,11 @@ export class ThemedObjectListComponent extends ThemedComponent