diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.ts b/src/app/admin/admin-sidebar/admin-sidebar.component.ts index d1aa05c1fc9..26ded965d4d 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.ts @@ -100,6 +100,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { } } }); + this.menuVisible = this.menuService.isMenuVisibleWithVisibleSections(this.menuID); } @HostListener('focusin') diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 3cb18bf515a..9e73ca9a2bc 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -29,5 +29,7 @@ export enum FeatureID { CanViewUsageStatistics = 'canViewUsageStatistics', CanSendFeedback = 'canSendFeedback', CanClaimItem = 'canClaimItem', - CanSynchronizeWithORCID = 'canSynchronizeWithORCID' + CanSynchronizeWithORCID = 'canSynchronizeWithORCID', + CanSubmit = 'canSubmit', + CanEditItem = 'canEditItem', } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 2c20ed0fb69..58ae7a15cd8 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -10,7 +10,7 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { ItemDataService } from './item-data.service'; -import { DeleteRequest, PostRequest } from './request.models'; +import { DeleteRequest, PostRequest, GetRequest } from './request.models'; import { RequestService } from './request.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { CoreState } from '../core-state.model'; @@ -192,6 +192,20 @@ describe('ItemDataService', () => { }); }); + describe('findItemsWithEdit', () => { + beforeEach(() => { + service = initTestService(); + }); + + it('should send a GET request', (done) => { + const result = service.findItemsWithEdit('', {}); + result.subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.any(GetRequest), true); + done(); + }); + }); + }); + describe('when cache is invalidated', () => { beforeEach(() => { service = initTestService(); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index 09268a0282d..58db598a1bc 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -46,16 +46,19 @@ import { RestRequestMethod } from './rest-request-method'; import { CreateData, CreateDataImpl } from './base/create-data'; import { RequestParam } from '../cache/models/request-param.model'; import { dataService } from './base/data-service.decorator'; +import { FollowLinkConfig } from 'src/app/shared/utils/follow-link-config.model'; +import { SearchData, SearchDataImpl } from './base/search-data'; /** * An abstract service for CRUD operations on Items * Doesn't specify an endpoint because multiple endpoints support Item-like functionality (e.g. items, itemtemplates) * Extend this class to implement data services for Items */ -export abstract class BaseItemDataService extends IdentifiableDataService implements CreateData, PatchData, DeleteData { +export abstract class BaseItemDataService extends IdentifiableDataService implements CreateData, PatchData, DeleteData, SearchData { private createData: CreateData; private patchData: PatchData; private deleteData: DeleteData; + private searchData: SearchData; protected constructor( protected linkPath, @@ -74,6 +77,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** @@ -388,6 +392,34 @@ export abstract class BaseItemDataService extends IdentifiableDataService return this.createData.create(object, ...params); } + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @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 + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Find the list of items for which the current user has editing rights. + * + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + public findItemsWithEdit(query: string, options: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + options = { ...options, searchParams: [new RequestParam('query', query)] }; + return this.searchBy('findItemsWithEdit', options, useCachedVersionIfAvailable, reRequestOnStale ); + } } /** diff --git a/src/app/core/data/remote-data.ts b/src/app/core/data/remote-data.ts index 40c8d7f4000..4f5e9349cba 100644 --- a/src/app/core/data/remote-data.ts +++ b/src/app/core/data/remote-data.ts @@ -114,5 +114,4 @@ export class RemoteData { get hasNoContent(): boolean { return this.statusCode === 204; } - } diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 32610c82fdf..1a92d953768 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -54,6 +54,15 @@ export const getFirstSucceededRemoteWithNotEmptyData = () => (source: Observable>): Observable> => source.pipe(find((rd: RemoteData) => rd.hasSucceeded && isNotEmpty(rd.payload))); +export const mapRemoteDataPayload = (fn: (payload: T) => U) => + (source: Observable>): Observable> => + source.pipe(map((rd: RemoteData) => { + let {payload, ...rest} = rd; + return Object.assign(new RemoteData(null,null,null,null), + {...rest, payload: fn(payload)}); + } +)); + /** * Get the first successful remotely retrieved object * diff --git a/src/app/menu.resolver.spec.ts b/src/app/menu.resolver.spec.ts index 4fd44efe667..eef5c2d5af4 100644 --- a/src/app/menu.resolver.spec.ts +++ b/src/app/menu.resolver.spec.ts @@ -142,29 +142,7 @@ describe('MenuResolver', () => { }); describe('createAdminMenu$', () => { - it('should retrieve the menu by ID return an Observable that emits true as soon as it is created', () => { - (menuService as any).getMenu.and.returnValue(cold('--u--m', { - u: undefined, - m: MENU_STATE, - })); - - expect(resolver.createAdminMenu$()).toBeObservable(cold('-----(t|)', BOOLEAN)); - expect(menuService.getMenu).toHaveBeenCalledOnceWith(MenuID.ADMIN); - }); - - describe('for regular user', () => { - beforeEach(() => { - authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake(() => { - return observableOf(false); - }); - }); - - beforeEach((done) => { - resolver.createAdminMenu$().subscribe((_) => { - done(); - }); - }); - + const dontShowAdminSections = () => { it('should not show site admin section', () => { expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ id: 'admin_search', visible: false, @@ -183,19 +161,6 @@ describe('MenuResolver', () => { })); }); - it('should not show edit_community', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'edit_community', visible: false, - })); - - }); - - it('should not show edit_collection', () => { - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ - id: 'edit_collection', visible: false, - })); - }); - it('should not show access control section', () => { expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ id: 'access_control', visible: false, @@ -222,6 +187,122 @@ describe('MenuResolver', () => { id: 'export', visible: true, })); }); + }; + + const dontShowNewSection = () => { + it('should not show the "New" section', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'new_community', visible: false, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'new_collection', visible: false, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'new_item', visible: false, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'new', visible: false, + })); + }); + }; + + const dontShowEditSection = () => { + it('should not show the "Edit" section', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'edit_community', visible: false, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'edit_collection', visible: false, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'edit_item', visible: false, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'edit', visible: false, + })); + }); + }; + + it('should retrieve the menu by ID return an Observable that emits true as soon as it is created', () => { + (menuService as any).getMenu.and.returnValue(cold('--u--m', { + u: undefined, + m: MENU_STATE, + })); + + expect(resolver.createAdminMenu$()).toBeObservable(cold('-----(t|)', BOOLEAN)); + expect(menuService.getMenu).toHaveBeenCalledOnceWith(MenuID.ADMIN); + }); + + describe('for regular user', () => { + beforeEach(() => { + authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID) => { + return observableOf(false); + }); + }); + + beforeEach((done) => { + resolver.createAdminMenu$().subscribe((_) => { + done(); + }); + }); + + dontShowAdminSections(); + dontShowNewSection(); + dontShowEditSection(); + }); + + describe('regular user who can submit', () => { + beforeEach(() => { + authorizationService.isAuthorized = createSpy('isAuthorized') + .and.callFake((featureID: FeatureID) => { + return observableOf(featureID === FeatureID.CanSubmit); + }); + }); + + beforeEach((done) => { + resolver.createAdminMenu$().subscribe((_) => { + done(); + }); + }); + + it('should show "New Item" section', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'new_item', visible: true, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'new', visible: true, + })); + }); + + dontShowAdminSections(); + dontShowEditSection(); + }); + + describe('regular user who can edit items', () => { + beforeEach(() => { + authorizationService.isAuthorized = createSpy('isAuthorized') + .and.callFake((featureID: FeatureID) => { + return observableOf(featureID === FeatureID.CanEditItem); + }); + }); + + beforeEach((done) => { + resolver.createAdminMenu$().subscribe((_) => { + done(); + }); + }); + + it('should show "Edit Item" section', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'edit_item', visible: true, + })); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'edit', visible: true, + })); + }); + + dontShowAdminSections(); + dontShowNewSection(); }); describe('for site admin', () => { @@ -237,6 +318,12 @@ describe('MenuResolver', () => { }); }); + it('should show new_process', () => { + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ + id: 'new_process', visible: true, + })); + }); + it('should contain site admin section', () => { expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ id: 'admin_search', visible: true, diff --git a/src/app/menu.resolver.ts b/src/app/menu.resolver.ts index 8630150c58f..eb13e3ec8b4 100644 --- a/src/app/menu.resolver.ts +++ b/src/app/menu.resolver.ts @@ -167,21 +167,11 @@ export class MenuResolver implements Resolve { combineLatest([ this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin), this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin), - this.authorizationService.isAuthorized(FeatureID.AdministratorOf) - ]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin]) => { - const menuList = [ - /* News */ - { - id: 'new', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.new' - } as TextMenuItemModel, - icon: 'plus', - index: 0 - }, + this.authorizationService.isAuthorized(FeatureID.AdministratorOf), + this.authorizationService.isAuthorized(FeatureID.CanSubmit), + this.authorizationService.isAuthorized(FeatureID.CanEditItem), + ]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin, canSubmit, canEditItem]) => { + const newSubMenuList = [ { id: 'new_community', parentID: 'new', @@ -212,7 +202,7 @@ export class MenuResolver implements Resolve { id: 'new_item', parentID: 'new', active: false, - visible: true, + visible: canSubmit, model: { type: MenuItemType.ONCLICK, text: 'menu.section.new_item', @@ -225,38 +215,16 @@ export class MenuResolver implements Resolve { id: 'new_process', parentID: 'new', active: false, - visible: isCollectionAdmin, + visible: isSiteAdmin, model: { type: MenuItemType.LINK, text: 'menu.section.new_process', link: '/processes/new' } as LinkMenuItemModel, }, - // TODO: enable this menu item once the feature has been implemented - // { - // id: 'new_item_version', - // parentID: 'new', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.new_item_version', - // link: '' - // } as LinkMenuItemModel, - // }, - + ]; + const editSubMenuList = [ /* Edit */ - { - id: 'edit', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.edit' - } as TextMenuItemModel, - icon: 'pencil-alt', - index: 1 - }, { id: 'edit_community', parentID: 'edit', @@ -287,7 +255,7 @@ export class MenuResolver implements Resolve { id: 'edit_item', parentID: 'edit', active: false, - visible: true, + visible: canEditItem, model: { type: MenuItemType.ONCLICK, text: 'menu.section.edit_item', @@ -296,6 +264,47 @@ export class MenuResolver implements Resolve { } } as OnClickMenuItemModel, }, + ]; + const newSubMenu = { + id: 'new', + active: false, + visible: newSubMenuList.some(subMenu => subMenu.visible), + model: { + type: MenuItemType.TEXT, + text: 'menu.section.new' + } as TextMenuItemModel, + icon: 'plus', + index: 0 + }; + const editSubMenu = { + id: 'edit', + active: false, + visible: editSubMenuList.some(subMenu => subMenu.visible), + model: { + type: MenuItemType.TEXT, + text: 'menu.section.edit' + } as TextMenuItemModel, + icon: 'pencil-alt', + index: 1 + }; + + const menuList = [ + ...newSubMenuList, + newSubMenu, + ...editSubMenuList, + editSubMenu, + // TODO: enable this menu item once the feature has been implemented + // { + // id: 'new_item_version', + // parentID: 'new', + // active: false, + // visible: true, + // model: { + // type: MenuItemType.LINK, + // text: 'menu.section.new_item_version', + // link: '' + // } as LinkMenuItemModel, + // }, /* Statistics */ // TODO: enable this menu item once the feature has been implemented diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index 99b14e64589..2bbeec6282b 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -61,7 +61,7 @@ export class RootComponent implements OnInit { } ngOnInit() { - this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN); + this.sidebarVisible = this.menuService.isMenuVisibleWithVisibleSections(MenuID.ADMIN); this.collapsedSidebarWidth = this.cssService.getVariable('--ds-collapsed-sidebar-width'); this.totalSidebarWidth = this.cssService.getVariable('--ds-total-sidebar-width'); diff --git a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts index cc1f9822d67..45b2f9e0090 100644 --- a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts +++ b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts @@ -3,8 +3,7 @@ import { DSOSelectorComponent } from '../dso-selector.component'; import { SearchService } from '../../../../core/shared/search/search.service'; import { CollectionDataService } from '../../../../core/data/collection-data.service'; import { Observable } from 'rxjs'; -import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; -import { map } from 'rxjs/operators'; +import { getFirstCompletedRemoteData, mapRemoteDataPayload } from '../../../../core/shared/operators'; import { CollectionSearchResult } from '../../../object-collection/shared/collection-search-result.model'; import { SearchResult } from '../../../search/models/search-result.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; @@ -74,9 +73,9 @@ export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent } return searchListService$.pipe( getFirstCompletedRemoteData(), - map((rd) => Object.assign(new RemoteData(null, null, null, null), rd, { - payload: hasValue(rd.payload) ? buildPaginatedList(rd.payload.pageInfo, rd.payload.page.map((col) => Object.assign(new CollectionSearchResult(), { indexableObject: col }))) : null, - })) - ); + mapRemoteDataPayload((payload) => hasValue(payload) + ? buildPaginatedList(payload.pageInfo, payload.page.map((col) => + Object.assign(new CollectionSearchResult(), { indexableObject: col }))) + : null)); } } diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts index c8d11891baa..83ab99685d2 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts @@ -174,10 +174,9 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { if (rd.hasSucceeded) { // If it's the first page and no query is entered, add the current DSO to the start of the list // If no query is entered, filter out the current DSO from the results, as it'll be displayed at the start of the list already - rd.payload.page = [ - ...((isEmpty(query) && page === 1) ? currentDSOResult.page : []), - ...rd.payload.page.filter((result) => isNotEmpty(query) || result.indexableObject.id !== this.currentDSOId) - ]; + const part1 = ((isEmpty(query) && page === 1) ? currentDSOResult.page : []); + const part2 = rd.payload.page.filter((result) => isNotEmpty(query) || result.indexableObject.id !== this.currentDSOId); + rd.payload.page = [ ...part1, ...part2 ]; } else if (rd.hasFailed) { this.notifcationsService.error(this.translate.instant('dso-selector.error.title', { type: this.typesString }), rd.errorMessage); } diff --git a/src/app/shared/dso-selector/dso-selector/editable-item-selector/editable-item-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/editable-item-selector/editable-item-selector.component.spec.ts new file mode 100644 index 00000000000..a716657fd8e --- /dev/null +++ b/src/app/shared/dso-selector/dso-selector/editable-item-selector/editable-item-selector.component.spec.ts @@ -0,0 +1,130 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import { EditableItemSelectorComponent } from './editable-item-selector.component'; +import { FindListOptions } from '../../../../core/data/find-list-options.model'; +import { Item } from '../../../../core/shared/item.model'; +import { createSuccessfulRemoteDataObject$, createFailedRemoteDataObject$ } from '../../../remote-data.utils'; +import { createPaginatedList } from '../../../testing/utils.test'; +import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('EditableItemSelectorComponent', () => { + let component: EditableItemSelectorComponent; + let fixture: ComponentFixture; + + function createFindItemsResult(name: string): Item { + return Object.assign(new Item(), { + id: `test-result-${name}`, + metadata: { + 'dc.title': [ + { + value: `test-result - ${name}` + } + ] + } + }); + } + + const currentDSOId = 'test-uuid-ford-sose'; + const type = DSpaceObjectType.ITEM; + const currentResult = createFindItemsResult('current'); + const pages = [ + ['1','2','3'].map(createFindItemsResult), + ['4','5','6'].map(createFindItemsResult), + ]; + +/* + beforeEach(() => { + dsoService = { + findByIdAndIDType: jasmine.createSpy('findByIdAndIDType').and.returnValue(observableOf({ hasFailed: false, + hasSucceeded: true })) + }; + guard = new LookupGuard(dsoService); + }); + */ + let itemDataService; + let notificationsService: NotificationsService; + + beforeEach(waitForAsync( () => { + notificationsService = jasmine.createSpyObj('notificationsService', ['error']); + itemDataService = { + findItemsWithEdit: jasmine.createSpy('findItemsWithEdit').and.callFake( + (query: string, options: FindListOptions, + useCachedVersionIfAvailable = true, reRequestOnStale = true) => { + return createSuccessfulRemoteDataObject$(createPaginatedList( + query.startsWith('search.resourceid') ? [currentResult] : pages[options.currentPage - 1] + )); + } + ) + }; + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [EditableItemSelectorComponent], + providers: [ + { provide: ItemDataService, useValue: itemDataService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: SearchService, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditableItemSelectorComponent); + component = fixture.componentInstance; + component.currentDSOId = currentDSOId; + component.types = [type]; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('populating listEntries', () => { + it('should not be empty', (done) => { + component.listEntries$.subscribe((listEntries) => { + expect(listEntries.length).toBeGreaterThan(0); + done(); + }); + }); + + it('should contain a combination of the current DSO and first page results', (done) => { + component.listEntries$.subscribe((listEntries) => { + expect(listEntries.map(entry => (entry as ItemSearchResult).indexableObject)) + .toEqual([currentResult, ...pages[0]]); + done(); + }); + }); + + describe('when current page increases', () => { + beforeEach(() => { + component.currentPage$.next(2); + }); + + it('should contain a combination of the current DSO, as well as first and second page results', (done) => { + component.listEntries$.subscribe((listEntries) => { + expect(listEntries.map(entry => (entry as ItemSearchResult).indexableObject)) + .toEqual([currentResult, ...pages[0], ...pages[1]]); + done(); + }); + }); + }); + + describe('when search returns an error', () => { + beforeEach(() => { + itemDataService.findItemsWithEdit.and.returnValue(createFailedRemoteDataObject$()); + component.ngOnInit(); + }); + + it('should display an error notification', () => { + expect(notificationsService.error).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/shared/dso-selector/dso-selector/editable-item-selector/editable-item-selector.component.ts b/src/app/shared/dso-selector/dso-selector/editable-item-selector/editable-item-selector.component.ts new file mode 100644 index 00000000000..0372f6b1cfb --- /dev/null +++ b/src/app/shared/dso-selector/dso-selector/editable-item-selector/editable-item-selector.component.ts @@ -0,0 +1,56 @@ +import { Component } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { DSONameService } from 'src/app/core/breadcrumbs/dso-name.service'; +import { ItemDataService } from 'src/app/core/data/item-data.service'; +import { buildPaginatedList, PaginatedList } from 'src/app/core/data/paginated-list.model'; +import { RemoteData } from 'src/app/core/data/remote-data'; +import { DSpaceObject } from 'src/app/core/shared/dspace-object.model'; +import { SearchService } from 'src/app/core/shared/search/search.service'; +import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; +import { SearchResult } from 'src/app/shared/search/models/search-result.model'; +import { DSOSelectorComponent } from '../dso-selector.component'; +import { followLink } from '../../../utils/follow-link-config.model'; +import { getFirstCompletedRemoteData, mapRemoteDataPayload } from 'src/app/core/shared/operators'; +import { hasValue } from 'src/app/shared/empty.util'; +import { ItemSearchResult } from 'src/app/shared/object-collection/shared/item-search-result.model'; +import { FindListOptions } from 'src/app/core/data/find-list-options.model'; + +@Component({ + selector: 'ds-editable-item-selector', + templateUrl: '../dso-selector.component.html', + styleUrls: ['../dso-selector.component.scss'] +}) +/** + * Component rendering a list of items that are editable by the current user. + */ +export class EditableItemSelectorComponent extends DSOSelectorComponent { + + constructor( + protected searchService: SearchService, + protected itemDataService: ItemDataService, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected dsoNameService: DSONameService + ) { + super(searchService, notificationsService, translate, dsoNameService); + } + + /* + * Find the list of items that can be edited by the current user. + */ + search(query: string, page: number): + Observable>>> { + const findOptions: FindListOptions = { + currentPage: page, + elementsPerPage: this.defaultPagination.pageSize + }; + return this.itemDataService.findItemsWithEdit(query, findOptions, true, true, followLink('owningCollection')).pipe( + getFirstCompletedRemoteData(), + mapRemoteDataPayload((payload) => hasValue(payload) + ? buildPaginatedList(payload.pageInfo, payload.page.map((item) => + Object.assign(new ItemSearchResult(), { indexableObject: item }))) + : null)); + } + +} diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html new file mode 100644 index 00000000000..d51dd1ceaf1 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts index 4822849e4cc..e13a00bc4c0 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts @@ -14,7 +14,8 @@ import { Item } from '../../../../core/shared/item.model'; @Component({ selector: 'ds-edit-item-selector', - templateUrl: '../dso-selector-modal-wrapper.component.html', + templateUrl: './edit-item-selector.component.html', + // templateUrl: '../dso-selector-modal-wrapper.component.html', }) export class EditItemSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit { objectType = DSpaceObjectType.ITEM; diff --git a/src/app/shared/menu/menu.service.spec.ts b/src/app/shared/menu/menu.service.spec.ts index 7a6b304ec13..589b5ebf6b8 100644 --- a/src/app/shared/menu/menu.service.spec.ts +++ b/src/app/shared/menu/menu.service.spec.ts @@ -243,6 +243,84 @@ describe('MenuService', () => { }); }); + describe('isMenuVisibleWithVisibleSections', () => { + it('should return false when the menu is empty', () => { + const testMenu = { + id: MenuID.ADMIN, + collapsed: false, + visible: true, + sections: {}, + previewCollapsed: false, + sectionToSubsectionIndex: {} + } as any; + spyOn(service, 'getMenu').and.returnValue(observableOf(testMenu)); + + const result = service.isMenuVisibleWithVisibleSections(MenuID.ADMIN); + const expected = cold('(b|)', { + b: false + }); + + expect(result).toBeObservable(expected); + }); + it('should return false when no top-level sections are visible', () => { + const noTopLevelVisibleSections = { + section: {id: 's1', visible: false}, + section_2: {id: 's2', visible: false}, + section_3: {id: 's3', visible: false}, + section_4: {id: 's1_1', visible: true, parentID: 's1'}, + section_5: {id: 's2_1', visible: true, parentID: 's2'}, + }; + const testMenu = { + id: MenuID.ADMIN, + collapsed: false, + visible: true, + sections: noTopLevelVisibleSections, + previewCollapsed: false, + sectionToSubsectionIndex: { + 'section': ['section_4'], + 'section_2': ['section_5'], + } + } as any; + spyOn(service, 'getMenu').and.returnValue(observableOf(testMenu)); + + const result = service.isMenuVisibleWithVisibleSections(MenuID.ADMIN); + const expected = cold('(b|)', { + b: false + }); + + expect(result).toBeObservable(expected); + }); + + it('should return true when any top-level section is visible', () => { + const noTopLevelVisibleSections = { + section: {id: 's1', visible: false}, + section_2: {id: 's2', visible: true}, + section_3: {id: 's3', visible: false}, + section_4: {id: 's1_1', visible: true, parentID: 's1'}, + section_5: {id: 's2_1', visible: true, parentID: 's2'}, + }; + const testMenu = { + id: MenuID.ADMIN, + collapsed: false, + visible: true, + sections: noTopLevelVisibleSections, + previewCollapsed: false, + sectionToSubsectionIndex: { + 'section': ['section_4'], + 'section_2': ['section_5'], + } + } as any; + spyOn(service, 'getMenu').and.returnValue(observableOf(testMenu)); + + const result = service.isMenuVisibleWithVisibleSections(MenuID.ADMIN); + const expected = cold('(b|)', { + b: true + }); + + expect(result).toBeObservable(expected); + }); + }); + describe('isMenuVisible', () => { beforeEach(() => { spyOn(service, 'getMenu').and.returnValue(observableOf(fakeMenu)); diff --git a/src/app/shared/menu/menu.service.ts b/src/app/shared/menu/menu.service.ts index f44ddea649b..087145ae822 100644 --- a/src/app/shared/menu/menu.service.ts +++ b/src/app/shared/menu/menu.service.ts @@ -181,6 +181,18 @@ export class MenuService { ); } + /** + * Check if a given menu is visible and has visible top-level (!) sections + * @param {MenuID} menuID The ID of the menu that is to be checked + * @returns {Observable} Emits true if the given menu is + * visible and has visible sections, emits false when it's hidden + */ + isMenuVisibleWithVisibleSections(menuID: MenuID): Observable { + return observableCombineLatest([this.isMenuVisible(menuID), this.menuHasVisibleSections(menuID)]).pipe( + map(([menuVisible, visibleSections]) => menuVisible && visibleSections) + ); + } + /** * Check if a given menu is visible * @param {MenuID} menuID The ID of the menu that is to be checked @@ -192,6 +204,20 @@ export class MenuService { ); } + /** + * Check if a menu has at least one top-level (!) section that is visible. + * @param {MenuID} menuID The ID of the menu that is to be checked + * @returns {Observable} Emits true if the given menu has visible sections, emits false otherwise + */ + menuHasVisibleSections(menuID: MenuID): Observable { + return this.getMenu(menuID).pipe( + map((state: MenuState) => hasValue(state) + ? Object.values(state.sections) + .some(section => section.visible && section.parentID === undefined) + : undefined) + ); + } + /** * Expands a given menu * @param {MenuID} menuID The ID of the menu diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 777ad03c1da..94926b457c9 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -237,6 +237,7 @@ import { } from '../item-page/simple/field-components/specific-field/title/item-page-title-field.component'; import { MarkdownPipe } from './utils/markdown.pipe'; import { GoogleRecaptchaModule } from '../core/google-recaptcha/google-recaptcha.module'; +import { EditableItemSelectorComponent } from './dso-selector/dso-selector/editable-item-selector/editable-item-selector.component'; import { MenuModule } from './menu/menu.module'; import { ListableNotificationObjectComponent @@ -245,7 +246,6 @@ import { ThemedCollectionDropdownComponent } from './collection-dropdown/themed- import { MetadataFieldWrapperComponent } from './metadata-field-wrapper/metadata-field-wrapper.component'; import { LogInExternalProviderComponent } from './log-in/methods/log-in-external-provider/log-in-external-provider.component'; - const MODULES = [ CommonModule, FormsModule, @@ -340,6 +340,7 @@ const COMPONENTS = [ ExportBatchSelectorComponent, ConfirmationModalComponent, AuthorizedCollectionSelectorComponent, + EditableItemSelectorComponent, SearchNavbarComponent, ItemPageTitleFieldComponent, ThemedSearchNavbarComponent, diff --git a/src/app/shared/testing/menu-service.stub.ts b/src/app/shared/testing/menu-service.stub.ts index 926232bad02..71ee7771573 100644 --- a/src/app/shared/testing/menu-service.stub.ts +++ b/src/app/shared/testing/menu-service.stub.ts @@ -66,6 +66,10 @@ export class MenuServiceStub { return observableOf(true); } + isMenuVisibleWithVisibleSections(id: MenuID): Observable { + return observableOf(true); + } + isMenuCollapsed(id: MenuID): Observable { return observableOf(false); } diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts index 398dbc933c0..50cf8c9a20c 100644 --- a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts @@ -5,8 +5,6 @@ import { @Component({ selector: 'ds-edit-item-selector', - // styleUrls: ['./edit-item-selector.component.scss'], - // templateUrl: './edit-item-selector.component.html', templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html', }) export class EditItemSelectorComponent extends BaseComponent {