0 && !(searching$ | async)"
+ *ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(loading$ | async)"
[paginationOptions]="config"
[pageInfoState]="pageInfoState$"
[collectionSize]="(pageInfoState$ | async)?.totalElements"
@@ -59,11 +59,23 @@
{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}
-
-
-
+
+
+
+
+
+
+
+
diff --git a/src/app/access-control/group-registry/groups-registry.component.spec.ts b/src/app/access-control/group-registry/groups-registry.component.spec.ts
index 10064800e1..245044f5c9 100644
--- a/src/app/access-control/group-registry/groups-registry.component.spec.ts
+++ b/src/app/access-control/group-registry/groups-registry.component.spec.ts
@@ -30,6 +30,7 @@ import { routeServiceStub } from '../../shared/testing/route-service.stub';
import { RouterMock } from '../../shared/mocks/router.mock';
import { PaginationService } from '../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
+import { FeatureID } from '../../core/data/feature-authorization/feature-id';
describe('GroupRegistryComponent', () => {
let component: GroupsRegistryComponent;
@@ -43,6 +44,26 @@ describe('GroupRegistryComponent', () => {
let mockEPeople;
let paginationService;
+ /**
+ * Set authorizationService.isAuthorized to return the following values.
+ * @param isAdmin whether or not the current user is an admin.
+ * @param canManageGroup whether or not the current user can manage all groups.
+ */
+ const setIsAuthorized = (isAdmin: boolean, canManageGroup: boolean) => {
+ (authorizationService as any).isAuthorized.and.callFake((featureId?: FeatureID) => {
+ switch (featureId) {
+ case FeatureID.AdministratorOf:
+ return observableOf(isAdmin);
+ case FeatureID.CanManageGroup:
+ return observableOf(canManageGroup);
+ case FeatureID.CanDelete:
+ return observableOf(true);
+ default:
+ throw new Error(`setIsAuthorized: this fake implementation does not support ${featureId}.`);
+ }
+ });
+ };
+
beforeEach(waitForAsync(() => {
mockGroups = [GroupMock, GroupMock2];
mockEPeople = [EPersonMock, EPersonMock2];
@@ -131,9 +152,8 @@ describe('GroupRegistryComponent', () => {
return createSuccessfulRemoteDataObject$(undefined);
}
};
- authorizationService = jasmine.createSpyObj('authorizationService', {
- isAuthorized: observableOf(true)
- });
+ authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']);
+ setIsAuthorized(true, true);
paginationService = new PaginationServiceStub();
TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
@@ -180,6 +200,81 @@ describe('GroupRegistryComponent', () => {
});
});
+ describe('edit buttons', () => {
+ describe('when the user is a general admin', () => {
+ beforeEach(fakeAsync(() => {
+ // NOTE: setting canManageGroup to false should not matter, since isAdmin takes priority
+ setIsAuthorized(true, false);
+
+ // force rerender after setup changes
+ component.search({ query: '' });
+ tick();
+ fixture.detectChanges();
+ }));
+
+ it('should be active', () => {
+ const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
+ expect(editButtonsFound.length).toEqual(2);
+ editButtonsFound.forEach((editButtonFound) => {
+ expect(editButtonFound.nativeElement.disabled).toBeFalse();
+ });
+ });
+
+ it('should not check the canManageGroup permissions', () => {
+ expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
+ FeatureID.CanManageGroup, mockGroups[0].self
+ );
+ expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
+ FeatureID.CanManageGroup, mockGroups[0].self, undefined // treated differently
+ );
+ expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
+ FeatureID.CanManageGroup, mockGroups[1].self
+ );
+ expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
+ FeatureID.CanManageGroup, mockGroups[1].self, undefined // treated differently
+ );
+ });
+ });
+
+ describe('when the user can edit the groups', () => {
+ beforeEach(fakeAsync(() => {
+ setIsAuthorized(false, true);
+
+ // force rerender after setup changes
+ component.search({ query: '' });
+ tick();
+ fixture.detectChanges();
+ }));
+
+ it('should be active', () => {
+ const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
+ expect(editButtonsFound.length).toEqual(2);
+ editButtonsFound.forEach((editButtonFound) => {
+ expect(editButtonFound.nativeElement.disabled).toBeFalse();
+ });
+ });
+ });
+
+ describe('when the user can not edit the groups', () => {
+ beforeEach(fakeAsync(() => {
+ setIsAuthorized(false, false);
+
+ // force rerender after setup changes
+ component.search({ query: '' });
+ tick();
+ fixture.detectChanges();
+ }));
+
+ it('should not be active', () => {
+ const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
+ expect(editButtonsFound.length).toEqual(2);
+ editButtonsFound.forEach((editButtonFound) => {
+ expect(editButtonFound.nativeElement.disabled).toBeTrue();
+ });
+ });
+ });
+ });
+
describe('search', () => {
describe('when searching with query', () => {
let groupIdsFound;
diff --git a/src/app/access-control/group-registry/groups-registry.component.ts b/src/app/access-control/group-registry/groups-registry.component.ts
index b28ac043d9..ce1b7dedd9 100644
--- a/src/app/access-control/group-registry/groups-registry.component.ts
+++ b/src/app/access-control/group-registry/groups-registry.component.ts
@@ -9,7 +9,7 @@ import {
of as observableOf,
Subscription
} from 'rxjs';
-import { catchError, map, switchMap, take } from 'rxjs/operators';
+import { catchError, map, switchMap, take, tap } from 'rxjs/operators';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
@@ -75,7 +75,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
/**
* A boolean representing if a search is pending
*/
- searching$: BehaviorSubject = new BehaviorSubject(false);
+ loading$: BehaviorSubject = new BehaviorSubject(false);
// Current search in groups registry
currentSearchQuery: string;
@@ -118,12 +118,12 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
* @param data Contains query param
*/
search(data: any) {
- this.searching$.next(true);
if (hasValue(this.searchSub)) {
this.searchSub.unsubscribe();
this.subs = this.subs.filter((sub: Subscription) => sub !== this.searchSub);
}
this.searchSub = this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
+ tap(() => this.loading$.next(true)),
switchMap((paginationOptions) => {
const query: string = data.query;
if (query != null && this.currentSearchQuery !== query) {
@@ -141,39 +141,53 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
if (groups.page.length === 0) {
return observableOf(buildPaginatedList(groups.pageInfo, []));
}
- return observableCombineLatest(groups.page.map((group: Group) => {
- if (!this.deletedGroupsIds.includes(group.id)) {
- return observableCombineLatest([
- this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(group) ? group.self : undefined),
- this.hasLinkedDSO(group),
- this.getSubgroups(group),
- this.getMembers(group)
- ]).pipe(
- map(([isAuthorized, hasLinkedDSO, subgroups, members]:
- [boolean, boolean, RemoteData>, RemoteData>]) => {
- const groupDtoModel: GroupDtoModel = new GroupDtoModel();
- groupDtoModel.ableToDelete = isAuthorized && !hasLinkedDSO;
- groupDtoModel.group = group;
- groupDtoModel.subgroups = subgroups.payload;
- groupDtoModel.epersons = members.payload;
- return groupDtoModel;
- }
- )
- );
- }
- })).pipe(map((dtos: GroupDtoModel[]) => {
- return buildPaginatedList(groups.pageInfo, dtos);
- }));
+ return this.authorizationService.isAuthorized(FeatureID.AdministratorOf).pipe(
+ switchMap((isSiteAdmin: boolean) => {
+ return observableCombineLatest(groups.page.map((group: Group) => {
+ if (hasValue(group) && !this.deletedGroupsIds.includes(group.id)) {
+ return observableCombineLatest([
+ this.authorizationService.isAuthorized(FeatureID.CanDelete, group.self),
+ this.canManageGroup$(isSiteAdmin, group),
+ this.hasLinkedDSO(group),
+ this.getSubgroups(group),
+ this.getMembers(group)
+ ]).pipe(
+ map(([canDelete, canManageGroup, hasLinkedDSO, subgroups, members]:
+ [boolean, boolean, boolean, RemoteData>, RemoteData>]) => {
+ const groupDtoModel: GroupDtoModel = new GroupDtoModel();
+ groupDtoModel.ableToDelete = canDelete && !hasLinkedDSO;
+ groupDtoModel.ableToEdit = canManageGroup;
+ groupDtoModel.group = group;
+ groupDtoModel.subgroups = subgroups.payload;
+ groupDtoModel.epersons = members.payload;
+ return groupDtoModel;
+ }
+ )
+ );
+ }
+ })).pipe(map((dtos: GroupDtoModel[]) => {
+ return buildPaginatedList(groups.pageInfo, dtos);
+ }));
+ })
+ );
})
).subscribe((value: PaginatedList) => {
this.groupsDto$.next(value);
this.pageInfoState$.next(value.pageInfo);
- this.searching$.next(false);
+ this.loading$.next(false);
});
this.subs.push(this.searchSub);
}
+ canManageGroup$(isSiteAdmin: boolean, group: Group): Observable {
+ if (isSiteAdmin) {
+ return observableOf(true);
+ } else {
+ return this.authorizationService.isAuthorized(FeatureID.CanManageGroup, group.self);
+ }
+ }
+
/**
* Delete Group
*/
diff --git a/src/app/community-list-page/community-list-service.ts b/src/app/community-list-page/community-list-service.ts
index cbf70ca39a..e882ae5902 100644
--- a/src/app/community-list-page/community-list-service.ts
+++ b/src/app/community-list-page/community-list-service.ts
@@ -174,8 +174,8 @@ export class CommunityListService {
direction: options.sort.direction
}
},
- followLink('subcommunities', this.configOnePage, true, true),
- followLink('collections', this.configOnePage, true, true))
+ followLink('subcommunities', { findListOptions: this.configOnePage }),
+ followLink('collections', { findListOptions: this.configOnePage }))
.pipe(
getFirstSucceededRemoteData(),
map((results) => results.payload),
@@ -242,8 +242,8 @@ export class CommunityListService {
elementsPerPage: MAX_COMCOLS_PER_PAGE,
currentPage: i
},
- followLink('subcommunities', this.configOnePage, true, true),
- followLink('collections', this.configOnePage, true, true))
+ followLink('subcommunities', { findListOptions: this.configOnePage }),
+ followLink('collections', { findListOptions: this.configOnePage }))
.pipe(
getFirstCompletedRemoteData(),
switchMap((rd: RemoteData>) => {
diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts
index f80be89034..e2cef3562f 100644
--- a/src/app/core/auth/auth.actions.ts
+++ b/src/app/core/auth/auth.actions.ts
@@ -292,10 +292,13 @@ export class ResetAuthenticationMessagesAction implements Action {
export class RetrieveAuthMethodsAction implements Action {
public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS;
- payload: AuthStatus;
+ payload: {
+ status: AuthStatus;
+ blocking: boolean;
+ };
- constructor(authStatus: AuthStatus) {
- this.payload = authStatus;
+ constructor(status: AuthStatus, blocking: boolean) {
+ this.payload = { status, blocking };
}
}
@@ -306,10 +309,14 @@ export class RetrieveAuthMethodsAction implements Action {
*/
export class RetrieveAuthMethodsSuccessAction implements Action {
public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS;
- payload: AuthMethod[];
- constructor(authMethods: AuthMethod[] ) {
- this.payload = authMethods;
+ payload: {
+ authMethods: AuthMethod[];
+ blocking: boolean;
+ };
+
+ constructor(authMethods: AuthMethod[], blocking: boolean ) {
+ this.payload = { authMethods, blocking };
}
}
@@ -320,6 +327,12 @@ export class RetrieveAuthMethodsSuccessAction implements Action {
*/
export class RetrieveAuthMethodsErrorAction implements Action {
public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR;
+
+ payload: boolean;
+
+ constructor(blocking: boolean) {
+ this.payload = blocking;
+ }
}
/**
diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts
index 5d530f39a9..cd4f456b44 100644
--- a/src/app/core/auth/auth.effects.spec.ts
+++ b/src/app/core/auth/auth.effects.spec.ts
@@ -43,10 +43,12 @@ describe('AuthEffects', () => {
let initialState;
let token;
let store: MockStore;
+ let authStatus;
function init() {
authServiceStub = new AuthServiceStub();
token = authServiceStub.getToken();
+ authStatus = Object.assign(new AuthStatus(), {});
initialState = {
core: {
auth: {
@@ -217,16 +219,38 @@ describe('AuthEffects', () => {
expect(authEffects.checkTokenCookie$).toBeObservable(expected);
});
- it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => {
- spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
- observableOf(
- { authenticated: false })
- );
- actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
+ describe('on CSR', () => {
+ it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => {
+ spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
+ observableOf(
+ { authenticated: false })
+ );
+ spyOn((authEffects as any).authService, 'getRetrieveAuthMethodsAction').and.returnValue(
+ new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, false)
+ );
+ actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
- const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus) });
+ const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, false) });
- expect(authEffects.checkTokenCookie$).toBeObservable(expected);
+ expect(authEffects.checkTokenCookie$).toBeObservable(expected);
+ });
+ });
+
+ describe('on SSR', () => {
+ it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => {
+ spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
+ observableOf(
+ { authenticated: false })
+ );
+ spyOn((authEffects as any).authService, 'getRetrieveAuthMethodsAction').and.returnValue(
+ new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, true)
+ );
+ actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
+
+ const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, true) });
+
+ expect(authEffects.checkTokenCookie$).toBeObservable(expected);
+ });
});
});
@@ -359,27 +383,74 @@ describe('AuthEffects', () => {
describe('retrieveMethods$', () => {
- describe('when retrieve authentication methods succeeded', () => {
- it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => {
- actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } });
+ describe('on CSR', () => {
+ describe('when retrieve authentication methods succeeded', () => {
+ it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => {
+ actions = hot('--a-', { a:
+ {
+ type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
+ payload: { status: authStatus, blocking: false}
+ }
+ });
- const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock) });
+ const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock, false) });
- expect(authEffects.retrieveMethods$).toBeObservable(expected);
+ expect(authEffects.retrieveMethods$).toBeObservable(expected);
+ });
+ });
+
+ describe('when retrieve authentication methods failed', () => {
+ it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => {
+ spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow(''));
+
+ actions = hot('--a-', { a:
+ {
+ type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
+ payload: { status: authStatus, blocking: false}
+ }
+ });
+
+ const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction(false) });
+
+ expect(authEffects.retrieveMethods$).toBeObservable(expected);
+ });
});
});
- describe('when retrieve authentication methods failed', () => {
- it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => {
- spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow(''));
+ describe('on SSR', () => {
+ describe('when retrieve authentication methods succeeded', () => {
+ it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => {
+ actions = hot('--a-', { a:
+ {
+ type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
+ payload: { status: authStatus, blocking: true}
+ }
+ });
- actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } });
+ const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock, true) });
- const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction() });
+ expect(authEffects.retrieveMethods$).toBeObservable(expected);
+ });
+ });
- expect(authEffects.retrieveMethods$).toBeObservable(expected);
+ describe('when retrieve authentication methods failed', () => {
+ it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => {
+ spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow(''));
+
+ actions = hot('--a-', { a:
+ {
+ type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
+ payload: { status: authStatus, blocking: true}
+ }
+ });
+
+ const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction(true) });
+
+ expect(authEffects.retrieveMethods$).toBeObservable(expected);
+ });
});
});
+
});
describe('clearInvalidTokenOnRehydrate$', () => {
diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts
index 9452af1fb8..2ef90dd76c 100644
--- a/src/app/core/auth/auth.effects.ts
+++ b/src/app/core/auth/auth.effects.ts
@@ -145,7 +145,7 @@ export class AuthEffects {
if (response.authenticated) {
return new RetrieveTokenAction();
} else {
- return new RetrieveAuthMethodsAction(response);
+ return this.authService.getRetrieveAuthMethodsAction(response);
}
}),
catchError((error) => observableOf(new AuthenticatedErrorAction(error)))
@@ -234,10 +234,10 @@ export class AuthEffects {
.pipe(
ofType(AuthActionTypes.RETRIEVE_AUTH_METHODS),
switchMap((action: RetrieveAuthMethodsAction) => {
- return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload)
+ return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload.status)
.pipe(
- map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels)),
- catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction()))
+ map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels, action.payload.blocking)),
+ catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction(action.payload.blocking)))
);
})
);
diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts
index 4c6f1e2a25..914a1a152d 100644
--- a/src/app/core/auth/auth.reducer.spec.ts
+++ b/src/app/core/auth/auth.reducer.spec.ts
@@ -512,7 +512,7 @@ describe('authReducer', () => {
loading: false,
authMethods: []
};
- const action = new RetrieveAuthMethodsAction(new AuthStatus());
+ const action = new RetrieveAuthMethodsAction(new AuthStatus(), true);
const newState = authReducer(initialState, action);
state = {
authenticated: false,
@@ -536,7 +536,7 @@ describe('authReducer', () => {
new AuthMethod(AuthMethodType.Password),
new AuthMethod(AuthMethodType.Shibboleth, 'location')
];
- const action = new RetrieveAuthMethodsSuccessAction(authMethods);
+ const action = new RetrieveAuthMethodsSuccessAction(authMethods, false);
const newState = authReducer(initialState, action);
state = {
authenticated: false,
@@ -548,7 +548,31 @@ describe('authReducer', () => {
expect(newState).toEqual(state);
});
- it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action', () => {
+ it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_SUCCESS action with blocking as true', () => {
+ initialState = {
+ authenticated: false,
+ loaded: false,
+ blocking: true,
+ loading: true,
+ authMethods: []
+ };
+ const authMethods = [
+ new AuthMethod(AuthMethodType.Password),
+ new AuthMethod(AuthMethodType.Shibboleth, 'location')
+ ];
+ const action = new RetrieveAuthMethodsSuccessAction(authMethods, true);
+ const newState = authReducer(initialState, action);
+ state = {
+ authenticated: false,
+ loaded: false,
+ blocking: true,
+ loading: false,
+ authMethods: authMethods
+ };
+ expect(newState).toEqual(state);
+ });
+
+ it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action ', () => {
initialState = {
authenticated: false,
loaded: false,
@@ -557,7 +581,7 @@ describe('authReducer', () => {
authMethods: []
};
- const action = new RetrieveAuthMethodsErrorAction();
+ const action = new RetrieveAuthMethodsErrorAction(false);
const newState = authReducer(initialState, action);
state = {
authenticated: false,
@@ -568,4 +592,25 @@ describe('authReducer', () => {
};
expect(newState).toEqual(state);
});
+
+ it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action with blocking as true', () => {
+ initialState = {
+ authenticated: false,
+ loaded: false,
+ blocking: true,
+ loading: true,
+ authMethods: []
+ };
+
+ const action = new RetrieveAuthMethodsErrorAction(true);
+ const newState = authReducer(initialState, action);
+ state = {
+ authenticated: false,
+ loaded: false,
+ blocking: true,
+ loading: false,
+ authMethods: [new AuthMethod(AuthMethodType.Password)]
+ };
+ expect(newState).toEqual(state);
+ });
});
diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts
index 6d5635f263..dfe29a3ef2 100644
--- a/src/app/core/auth/auth.reducer.ts
+++ b/src/app/core/auth/auth.reducer.ts
@@ -10,6 +10,7 @@ import {
RedirectWhenTokenExpiredAction,
RefreshTokenSuccessAction,
RetrieveAuthenticatedEpersonSuccessAction,
+ RetrieveAuthMethodsErrorAction,
RetrieveAuthMethodsSuccessAction,
SetRedirectUrlAction
} from './auth.actions';
@@ -211,14 +212,14 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS:
return Object.assign({}, state, {
loading: false,
- blocking: false,
- authMethods: (action as RetrieveAuthMethodsSuccessAction).payload
+ blocking: (action as RetrieveAuthMethodsSuccessAction).payload.blocking,
+ authMethods: (action as RetrieveAuthMethodsSuccessAction).payload.authMethods
});
case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR:
return Object.assign({}, state, {
loading: false,
- blocking: false,
+ blocking: (action as RetrieveAuthMethodsErrorAction).payload,
authMethods: [new AuthMethod(AuthMethodType.Password)]
});
diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts
index fa29f1bc36..ed4fca615c 100644
--- a/src/app/core/auth/auth.service.ts
+++ b/src/app/core/auth/auth.service.ts
@@ -35,6 +35,7 @@ import { AppState } from '../../app.reducer';
import {
CheckAuthenticationTokenAction,
ResetAuthenticationMessagesAction,
+ RetrieveAuthMethodsAction,
SetRedirectUrlAction
} from './auth.actions';
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
@@ -518,4 +519,13 @@ export class AuthService {
);
}
+ /**
+ * Return a new instance of RetrieveAuthMethodsAction
+ *
+ * @param authStatus The auth status
+ */
+ getRetrieveAuthMethodsAction(authStatus: AuthStatus): RetrieveAuthMethodsAction {
+ return new RetrieveAuthMethodsAction(authStatus, false);
+ }
+
}
diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts
index 9840b22267..cccc1490f8 100644
--- a/src/app/core/auth/server-auth.service.ts
+++ b/src/app/core/auth/server-auth.service.ts
@@ -10,6 +10,7 @@ import { AuthService } from './auth.service';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { RemoteData } from '../data/remote-data';
+import { RetrieveAuthMethodsAction } from './auth.actions';
/**
* The auth service.
@@ -60,4 +61,13 @@ export class ServerAuthService extends AuthService {
map((rd: RemoteData) => Object.assign(new AuthStatus(), rd.payload))
);
}
+
+ /**
+ * Return a new instance of RetrieveAuthMethodsAction
+ *
+ * @param authStatus The auth status
+ */
+ getRetrieveAuthMethodsAction(authStatus: AuthStatus): RetrieveAuthMethodsAction {
+ return new RetrieveAuthMethodsAction(authStatus, true);
+ }
}
diff --git a/src/app/core/cache/builders/link.service.spec.ts b/src/app/core/cache/builders/link.service.spec.ts
index a6d9c59492..f567c39314 100644
--- a/src/app/core/cache/builders/link.service.spec.ts
+++ b/src/app/core/cache/builders/link.service.spec.ts
@@ -102,7 +102,7 @@ describe('LinkService', () => {
describe('resolveLink', () => {
describe(`when the linkdefinition concerns a single object`, () => {
beforeEach(() => {
- service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor')));
+ service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
});
it('should call dataservice.findByHref with the correct href and nested links', () => {
expect(testDataService.findByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, true, true, followLink('successor'));
@@ -116,7 +116,7 @@ describe('LinkService', () => {
propertyName: 'predecessor',
isList: true
});
- service.resolveLink(testModel, followLink('predecessor', { some: 'options ' } as any, true, true, true, followLink('successor')));
+ service.resolveLink(testModel, followLink('predecessor', { findListOptions: { some: 'options ' } as any }, followLink('successor')));
});
it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => {
expect(testDataService.findAllByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor'));
@@ -124,7 +124,7 @@ describe('LinkService', () => {
});
describe('either way', () => {
beforeEach(() => {
- result = service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor')));
+ result = service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
});
it('should call getLinkDefinition with the correct model and link', () => {
@@ -149,7 +149,7 @@ describe('LinkService', () => {
});
it('should throw an error', () => {
expect(() => {
- service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor')));
+ service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
}).toThrow();
});
});
@@ -160,7 +160,7 @@ describe('LinkService', () => {
});
it('should throw an error', () => {
expect(() => {
- service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor')));
+ service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
}).toThrow();
});
});
diff --git a/src/app/core/cache/builders/link.service.ts b/src/app/core/cache/builders/link.service.ts
index 29f8633da5..66f91dbbd6 100644
--- a/src/app/core/cache/builders/link.service.ts
+++ b/src/app/core/cache/builders/link.service.ts
@@ -39,7 +39,7 @@ export class LinkService {
*/
public resolveLinks(model: T, ...linksToFollow: FollowLinkConfig[]): T {
linksToFollow.forEach((linkToFollow: FollowLinkConfig) => {
- this.resolveLink(model, linkToFollow);
+ this.resolveLink(model, linkToFollow);
});
return model;
}
@@ -55,9 +55,7 @@ export class LinkService {
public resolveLinkWithoutAttaching(model, linkToFollow: FollowLinkConfig): Observable> {
const matchingLinkDef = this.getLinkDefinition(model.constructor, linkToFollow.name);
- if (hasNoValue(matchingLinkDef)) {
- throw new Error(`followLink('${linkToFollow.name}') was used for a ${model.constructor.name}, but there is no property on ${model.constructor.name} models with an @link() for ${linkToFollow.name}`);
- } else {
+ if (hasValue(matchingLinkDef)) {
const provider = this.getDataServiceFor(matchingLinkDef.resourceType);
if (hasNoValue(provider)) {
@@ -84,7 +82,10 @@ export class LinkService {
throw e;
}
}
+ } else if (!linkToFollow.isOptional) {
+ throw new Error(`followLink('${linkToFollow.name}') was used as a required link for a ${model.constructor.name}, but there is no property on ${model.constructor.name} models with an @link() for ${linkToFollow.name}`);
}
+
return EMPTY;
}
diff --git a/src/app/core/cache/builders/remote-data-build.service.spec.ts b/src/app/core/cache/builders/remote-data-build.service.spec.ts
index cb193724a7..0cb45733a6 100644
--- a/src/app/core/cache/builders/remote-data-build.service.spec.ts
+++ b/src/app/core/cache/builders/remote-data-build.service.spec.ts
@@ -523,7 +523,7 @@ describe('RemoteDataBuildService', () => {
let paginatedLinksToFollow;
beforeEach(() => {
paginatedLinksToFollow = [
- followLink('page', undefined, true, true, true, ...linksToFollow),
+ followLink('page', {}, ...linksToFollow),
...linksToFollow
];
});
diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts
index 11815c133b..6b67549f2d 100644
--- a/src/app/core/cache/builders/remote-data-build.service.ts
+++ b/src/app/core/cache/builders/remote-data-build.service.ts
@@ -271,7 +271,7 @@ export class RemoteDataBuildService {
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
buildList(href$: string | Observable, ...linksToFollow: FollowLinkConfig[]): Observable>> {
- return this.buildFromHref>(href$, followLink('page', undefined, false, true, true, ...linksToFollow));
+ return this.buildFromHref>(href$, followLink('page', { shouldEmbed: false }, ...linksToFollow));
}
/**
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts
index 615c2b3977..3c34e5ec35 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -162,6 +162,7 @@ import { UsageReport } from './statistics/models/usage-report.model';
import { RootDataService } from './data/root-data.service';
import { Root } from './data/root.model';
import { SearchConfig } from './shared/search/search-filters/search-config.model';
+import { SequenceService } from './shared/sequence.service';
/**
* When not in production, endpoint responses can be mocked for testing purposes
@@ -282,7 +283,8 @@ const PROVIDERS = [
FilteredDiscoveryPageResponseParsingService,
{ provide: NativeWindowService, useFactory: NativeWindowFactory },
VocabularyService,
- VocabularyTreeviewService
+ VocabularyTreeviewService,
+ SequenceService,
];
/**
diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts
index 077aa3dc95..448c1b8641 100644
--- a/src/app/core/core.reducers.ts
+++ b/src/app/core/core.reducers.ts
@@ -13,6 +13,7 @@ import {
BitstreamFormatRegistryState
} from '../+admin/admin-registries/bitstream-formats/bitstream-format.reducers';
import { historyReducer, HistoryState } from './history/history.reducer';
+import { metaTagReducer, MetaTagState } from './metadata/meta-tag.reducer';
export interface CoreState {
'bitstreamFormats': BitstreamFormatRegistryState;
@@ -24,6 +25,7 @@ export interface CoreState {
'index': MetaIndexState;
'auth': AuthState;
'json/patch': JsonPatchOperationsState;
+ 'metaTag': MetaTagState;
'route': RouteState;
}
@@ -37,5 +39,6 @@ export const coreReducers: ActionReducerMap = {
'index': indexReducer,
'auth': authReducer,
'json/patch': jsonPatchOperationsReducer,
+ 'metaTag': metaTagReducer,
'route': routeReducer
};
diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts
index 1a16abc47f..3890f4e663 100644
--- a/src/app/core/data/bitstream-data.service.ts
+++ b/src/app/core/data/bitstream-data.service.ts
@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators';
-import { hasValue, isNotEmpty } from '../../shared/empty.util';
+import { hasValue } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { dataService } from '../cache/builders/build-decorators';
@@ -18,7 +18,7 @@ import { Item } from '../shared/item.model';
import { BundleDataService } from './bundle-data.service';
import { DataService } from './data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
-import { PaginatedList, buildPaginatedList } from './paginated-list.model';
+import { buildPaginatedList, PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data';
import { FindListOptions, PutRequest } from './request.models';
import { RequestService } from './request.service';
@@ -28,7 +28,6 @@ import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import { sendRequest } from '../shared/operators';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { PageInfo } from '../shared/page-info.model';
-import { RequestEntryState } from './request.reducer';
/**
* A service to retrieve {@link Bitstream}s from the REST API
@@ -75,92 +74,6 @@ export class BitstreamDataService extends DataService {
return this.findAllByHref(bundle._links.bitstreams.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
- /**
- * Retrieves the thumbnail for the given item
- * @returns {Observable>} the first bitstream in the THUMBNAIL bundle
- */
- // TODO should be implemented rest side. {@link Item} should get a thumbnail link
- public getThumbnailFor(item: Item): Observable> {
- return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe(
- switchMap((bundleRD: RemoteData) => {
- if (isNotEmpty(bundleRD.payload)) {
- return this.findAllByBundle(bundleRD.payload, { elementsPerPage: 1 }).pipe(
- map((bitstreamRD: RemoteData>) => {
- if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) {
- return new RemoteData(
- bitstreamRD.timeCompleted,
- bitstreamRD.msToLive,
- bitstreamRD.lastUpdated,
- bitstreamRD.state,
- bitstreamRD.errorMessage,
- bitstreamRD.payload.page[0],
- bitstreamRD.statusCode
- );
- } else {
- return bitstreamRD as any;
- }
- })
- );
- } else {
- return [bundleRD as any];
- }
- })
- );
- }
-
- /**
- * Retrieve the matching thumbnail for a {@link Bitstream}.
- *
- * The {@link Item} is technically redundant, but is available
- * in all current use cases, and having it simplifies this method
- *
- * @param item The {@link Item} the {@link Bitstream} and its thumbnail are a part of
- * @param bitstreamInOriginal The original {@link Bitstream} to find the thumbnail for
- */
- // TODO should be implemented rest side
- public getMatchingThumbnail(item: Item, bitstreamInOriginal: Bitstream): Observable> {
- return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe(
- switchMap((bundleRD: RemoteData) => {
- if (isNotEmpty(bundleRD.payload)) {
- return this.findAllByBundle(bundleRD.payload, { elementsPerPage: 9999 }).pipe(
- map((bitstreamRD: RemoteData>) => {
- if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) {
- const matchingThumbnail = bitstreamRD.payload.page.find((thumbnail: Bitstream) =>
- thumbnail.name.startsWith(bitstreamInOriginal.name)
- );
- if (hasValue(matchingThumbnail)) {
- return new RemoteData(
- bitstreamRD.timeCompleted,
- bitstreamRD.msToLive,
- bitstreamRD.lastUpdated,
- bitstreamRD.state,
- bitstreamRD.errorMessage,
- matchingThumbnail,
- bitstreamRD.statusCode
- );
- } else {
- return new RemoteData(
- bitstreamRD.timeCompleted,
- bitstreamRD.msToLive,
- bitstreamRD.lastUpdated,
- RequestEntryState.Error,
- 'No matching thumbnail found',
- undefined,
- 404
- );
- }
- } else {
- return bitstreamRD as any;
- }
- })
- );
- } else {
- return [bundleRD as any];
- }
- })
- );
- }
-
/**
* Retrieve all {@link Bitstream}s in a certain {@link Bundle}.
*
diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts
index 88b15754af..5bc7423824 100644
--- a/src/app/core/data/data.service.spec.ts
+++ b/src/app/core/data/data.service.spec.ts
@@ -233,7 +233,7 @@ describe('DataService', () => {
const config: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 5
});
- (service as any).getFindAllHref({}, null, followLink('bundles', config, true, true, true)).subscribe((value) => {
+ (service as any).getFindAllHref({}, null, followLink('bundles', { findListOptions: config })).subscribe((value) => {
expect(value).toBe(expected);
});
});
@@ -253,7 +253,7 @@ describe('DataService', () => {
elementsPerPage: 2
});
- (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', config, true, true, true), followLink('templateItemOf')).subscribe((value) => {
+ (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', { findListOptions: config }), followLink('templateItemOf')).subscribe((value) => {
expect(value).toBe(expected);
});
});
@@ -261,7 +261,13 @@ describe('DataService', () => {
it('should not include linksToFollow with shouldEmbed = false', () => {
const expected = `${endpoint}?embed=templateItemOf`;
- (service as any).getFindAllHref({}, null, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf')).subscribe((value) => {
+ (service as any).getFindAllHref(
+ {},
+ null,
+ followLink('bundles', { shouldEmbed: false }),
+ followLink('owningCollection', { shouldEmbed: false }),
+ followLink('templateItemOf')
+ ).subscribe((value) => {
expect(value).toBe(expected);
});
});
@@ -269,7 +275,7 @@ describe('DataService', () => {
it('should include nested linksToFollow 3lvl', () => {
const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`;
- (service as any).getFindAllHref({}, null, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', undefined, true, true, true, followLink('relationships')))).subscribe((value) => {
+ (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))).subscribe((value) => {
expect(value).toBe(expected);
});
});
@@ -279,7 +285,7 @@ describe('DataService', () => {
const config: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 4
});
- (service as any).getFindAllHref({}, null, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', config, true, true, true))).subscribe((value) => {
+ (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', { findListOptions: config }))).subscribe((value) => {
expect(value).toBe(expected);
});
});
@@ -308,13 +314,19 @@ describe('DataService', () => {
it('should not include linksToFollow with shouldEmbed = false', () => {
const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`;
- const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf'));
+ const result = (service as any).getIDHref(
+ endpointMock,
+ resourceIdMock,
+ followLink('bundles', { shouldEmbed: false }),
+ followLink('owningCollection', { shouldEmbed: false }),
+ followLink('templateItemOf')
+ );
expect(result).toEqual(expected);
});
it('should include nested linksToFollow 3lvl', () => {
const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`;
- const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', undefined, true, true, true,followLink('itemtemplate', undefined, true, true, true, followLink('relationships'))));
+ const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships'))));
expect(result).toEqual(expected);
});
});
diff --git a/src/app/core/data/dso-redirect-data.service.spec.ts b/src/app/core/data/dso-redirect-data.service.spec.ts
index d64f37ad78..bcd25487c2 100644
--- a/src/app/core/data/dso-redirect-data.service.spec.ts
+++ b/src/app/core/data/dso-redirect-data.service.spec.ts
@@ -119,7 +119,7 @@ describe('DsoRedirectDataService', () => {
});
it('should navigate to entities route with the corresponding entity type', () => {
remoteData.payload.type = 'item';
- remoteData.payload.metadata = {
+ remoteData.payload.metadata = {
'dspace.entity.type': [
{
language: 'en_US',
@@ -174,13 +174,29 @@ describe('DsoRedirectDataService', () => {
it('should not include linksToFollow with shouldEmbed = false', () => {
const expected = `${requestUUIDURL}&embed=templateItemOf`;
- const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf'));
+ const result = (service as any).getIDHref(
+ pidLink,
+ dsoUUID,
+ followLink('bundles', { shouldEmbed: false }),
+ followLink('owningCollection', { shouldEmbed: false }),
+ followLink('templateItemOf')
+ );
expect(result).toEqual(expected);
});
it('should include nested linksToFollow 3lvl', () => {
const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`;
- const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', undefined, true, true, true, followLink('relationships'))));
+ const result = (service as any).getIDHref(
+ pidLink,
+ dsoUUID,
+ followLink('owningCollection',
+ {},
+ followLink('itemtemplate',
+ {},
+ followLink('relationships')
+ )
+ )
+ );
expect(result).toEqual(expected);
});
});
diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts
index 6d070fcd4c..ac045b93b0 100644
--- a/src/app/core/data/feature-authorization/feature-id.ts
+++ b/src/app/core/data/feature-authorization/feature-id.ts
@@ -10,6 +10,7 @@ export enum FeatureID {
ReinstateItem = 'reinstateItem',
EPersonRegistration = 'epersonRegistration',
CanManageGroups = 'canManageGroups',
+ CanManageGroup = 'canManageGroup',
IsCollectionAdmin = 'isCollectionAdmin',
IsCommunityAdmin = 'isCommunityAdmin',
CanDownload = 'canDownload',
diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts
index e56f9f2b0c..7a0116fe86 100644
--- a/src/app/core/data/item-data.service.ts
+++ b/src/app/core/data/item-data.service.ts
@@ -23,14 +23,7 @@ import { DataService } from './data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data';
-import {
- DeleteRequest,
- FindListOptions,
- GetRequest,
- PostRequest,
- PutRequest,
- RestRequest
-} from './request.models';
+import { DeleteRequest, FindListOptions, GetRequest, PostRequest, PutRequest, RestRequest } from './request.models';
import { RequestService } from './request.service';
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
import { Bundle } from '../shared/bundle.model';
@@ -38,6 +31,9 @@ import { MetadataMap } from '../shared/metadata.models';
import { BundleDataService } from './bundle-data.service';
import { Operation } from 'fast-json-patch';
import { NoContent } from '../shared/NoContent.model';
+import { GenericConstructor } from '../shared/generic-constructor';
+import { ResponseParsingService } from './parsing.service';
+import { StatusCodeOnlyResponseParsingService } from './status-code-only-response-parsing.service';
@Injectable()
@dataService(ITEM)
@@ -229,7 +225,7 @@ export class ItemDataService extends DataService- {
* @param itemId
* @param collection
*/
- public moveToCollection(itemId: string, collection: Collection): Observable
> {
+ public moveToCollection(itemId: string, collection: Collection): Observable> {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'text/uri-list');
@@ -242,9 +238,17 @@ export class ItemDataService extends DataService- {
find((href: string) => hasValue(href)),
map((href: string) => {
const request = new PutRequest(requestId, href, collection._links.self.href, options);
- this.requestService.send(request);
+ Object.assign(request, {
+ // TODO: for now, the move Item endpoint returns a malformed collection -- only look at the status code
+ getResponseParser(): GenericConstructor
{
+ return StatusCodeOnlyResponseParsingService;
+ }
+ });
+ return request;
})
- ).subscribe();
+ ).subscribe((request) => {
+ this.requestService.send(request);
+ });
return this.rdbService.buildFromRequestUUID(requestId);
}
diff --git a/src/app/core/eperson/models/group-dto.model.ts b/src/app/core/eperson/models/group-dto.model.ts
index 47a70cf326..ef65c498fd 100644
--- a/src/app/core/eperson/models/group-dto.model.ts
+++ b/src/app/core/eperson/models/group-dto.model.ts
@@ -17,6 +17,11 @@ export class GroupDtoModel {
*/
public ableToDelete: boolean;
+ /**
+ * Whether or not the current user is able to edit the linked group
+ */
+ public ableToEdit: boolean;
+
/**
* List of subgroups of this group
*/
diff --git a/src/app/core/json-patch/builder/json-patch-operations-builder.ts b/src/app/core/json-patch/builder/json-patch-operations-builder.ts
index ced3750834..d3896c4a6c 100644
--- a/src/app/core/json-patch/builder/json-patch-operations-builder.ts
+++ b/src/app/core/json-patch/builder/json-patch-operations-builder.ts
@@ -9,7 +9,7 @@ import {
import { JsonPatchOperationPathObject } from './json-patch-operation-path-combiner';
import { Injectable } from '@angular/core';
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
-import { dateToISOFormat } from '../../../shared/date.util';
+import { dateToISOFormat, dateToString, isNgbDateStruct } from '../../../shared/date.util';
import { VocabularyEntry } from '../../submission/vocabularies/models/vocabulary-entry.model';
import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model';
import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model';
@@ -136,6 +136,8 @@ export class JsonPatchOperationsBuilder {
operationValue = new FormFieldMetadataValueObject(value.value, value.language);
} else if (value.hasOwnProperty('authority')) {
operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.authority);
+ } else if (isNgbDateStruct(value)) {
+ operationValue = new FormFieldMetadataValueObject(dateToString(value));
} else if (value.hasOwnProperty('value')) {
operationValue = new FormFieldMetadataValueObject(value.value);
} else {
diff --git a/src/app/core/json-patch/json-patch-operations.actions.ts b/src/app/core/json-patch/json-patch-operations.actions.ts
index 4c26703472..91fc6cb0da 100644
--- a/src/app/core/json-patch/json-patch-operations.actions.ts
+++ b/src/app/core/json-patch/json-patch-operations.actions.ts
@@ -20,6 +20,7 @@ export const JsonPatchOperationsActionTypes = {
ROLLBACK_JSON_PATCH_OPERATIONS: type('dspace/core/patch/ROLLBACK_JSON_PATCH_OPERATIONS'),
FLUSH_JSON_PATCH_OPERATIONS: type('dspace/core/patch/FLUSH_JSON_PATCH_OPERATIONS'),
START_TRANSACTION_JSON_PATCH_OPERATIONS: type('dspace/core/patch/START_TRANSACTION_JSON_PATCH_OPERATIONS'),
+ DELETE_PENDING_JSON_PATCH_OPERATIONS: type('dspace/core/patch/DELETE_PENDING_JSON_PATCH_OPERATIONS'),
};
/* tslint:disable:max-classes-per-file */
@@ -261,6 +262,13 @@ export class NewPatchReplaceOperationAction implements Action {
}
}
+/**
+ * An ngrx action to delete all pending JSON Patch Operations.
+ */
+export class DeletePendingJsonPatchOperationsAction implements Action {
+ type = JsonPatchOperationsActionTypes.DELETE_PENDING_JSON_PATCH_OPERATIONS;
+}
+
/* tslint:enable:max-classes-per-file */
/**
@@ -276,4 +284,5 @@ export type PatchOperationsActions
| NewPatchRemoveOperationAction
| NewPatchReplaceOperationAction
| RollbacktPatchOperationsAction
- | StartTransactionPatchOperationsAction;
+ | StartTransactionPatchOperationsAction
+ | DeletePendingJsonPatchOperationsAction;
diff --git a/src/app/core/json-patch/json-patch-operations.reducer.spec.ts b/src/app/core/json-patch/json-patch-operations.reducer.spec.ts
index 34378b819b..1f98cf0920 100644
--- a/src/app/core/json-patch/json-patch-operations.reducer.spec.ts
+++ b/src/app/core/json-patch/json-patch-operations.reducer.spec.ts
@@ -1,7 +1,7 @@
import * as deepFreeze from 'deep-freeze';
import {
- CommitPatchOperationsAction,
+ CommitPatchOperationsAction, DeletePendingJsonPatchOperationsAction,
FlushPatchOperationsAction,
NewPatchAddOperationAction,
NewPatchRemoveOperationAction,
@@ -323,4 +323,19 @@ describe('jsonPatchOperationsReducer test suite', () => {
});
+ describe('When DeletePendingJsonPatchOperationsAction has been dispatched', () => {
+ it('should set set the JsonPatchOperationsState to null ', () => {
+ const action = new DeletePendingJsonPatchOperationsAction();
+ initState = Object.assign({}, testState, {
+ [testJsonPatchResourceType]: Object.assign({}, testState[testJsonPatchResourceType], {
+ transactionStartTime: startTimestamp,
+ commitPending: true
+ })
+ });
+ const newState = jsonPatchOperationsReducer(initState, action);
+
+ expect(newState).toBeNull();
+ });
+ });
+
});
diff --git a/src/app/core/json-patch/json-patch-operations.reducer.ts b/src/app/core/json-patch/json-patch-operations.reducer.ts
index 8d630dbfa1..5e00027edb 100644
--- a/src/app/core/json-patch/json-patch-operations.reducer.ts
+++ b/src/app/core/json-patch/json-patch-operations.reducer.ts
@@ -11,7 +11,8 @@ import {
NewPatchReplaceOperationAction,
CommitPatchOperationsAction,
StartTransactionPatchOperationsAction,
- RollbacktPatchOperationsAction
+ RollbacktPatchOperationsAction,
+ DeletePendingJsonPatchOperationsAction
} from './json-patch-operations.actions';
import { JsonPatchOperationModel, JsonPatchOperationType } from './json-patch.model';
@@ -101,6 +102,10 @@ export function jsonPatchOperationsReducer(state = initialState, action: PatchOp
return startTransactionPatchOperations(state, action as StartTransactionPatchOperationsAction);
}
+ case JsonPatchOperationsActionTypes.DELETE_PENDING_JSON_PATCH_OPERATIONS: {
+ return deletePendingOperations(state, action as DeletePendingJsonPatchOperationsAction);
+ }
+
default: {
return state;
}
@@ -178,6 +183,20 @@ function rollbackOperations(state: JsonPatchOperationsState, action: RollbacktPa
}
}
+/**
+ * Set the JsonPatchOperationsState to its initial value.
+ *
+ * @param state
+ * the current state
+ * @param action
+ * an DeletePendingJsonPatchOperationsAction
+ * @return JsonPatchOperationsState
+ * the new state.
+ */
+function deletePendingOperations(state: JsonPatchOperationsState, action: DeletePendingJsonPatchOperationsAction): JsonPatchOperationsState {
+ return null;
+}
+
/**
* Add new JSON patch operation list.
*
diff --git a/src/app/core/json-patch/json-patch-operations.service.spec.ts b/src/app/core/json-patch/json-patch-operations.service.spec.ts
index 97aba52e56..d1b2948777 100644
--- a/src/app/core/json-patch/json-patch-operations.service.spec.ts
+++ b/src/app/core/json-patch/json-patch-operations.service.spec.ts
@@ -17,6 +17,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
import { JsonPatchOperationsEntry, JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer';
import {
CommitPatchOperationsAction,
+ DeletePendingJsonPatchOperationsAction,
RollbacktPatchOperationsAction,
StartTransactionPatchOperationsAction
} from './json-patch-operations.actions';
@@ -288,4 +289,19 @@ describe('JsonPatchOperationsService test suite', () => {
});
});
+ describe('deletePendingJsonPatchOperations', () => {
+ beforeEach(() => {
+ store.dispatch.and.callFake(() => { /* */ });
+ });
+
+ it('should dispatch a new DeletePendingJsonPatchOperationsAction', () => {
+
+ const expectedAction = new DeletePendingJsonPatchOperationsAction();
+ scheduler.schedule(() => service.deletePendingJsonPatchOperations());
+ scheduler.flush();
+
+ expect(store.dispatch).toHaveBeenCalledWith(expectedAction);
+ });
+ });
+
});
diff --git a/src/app/core/json-patch/json-patch-operations.service.ts b/src/app/core/json-patch/json-patch-operations.service.ts
index 84d946daba..c3363f4db4 100644
--- a/src/app/core/json-patch/json-patch-operations.service.ts
+++ b/src/app/core/json-patch/json-patch-operations.service.ts
@@ -10,7 +10,7 @@ import { CoreState } from '../core.reducers';
import { jsonPatchOperationsByResourceType } from './selectors';
import { JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer';
import {
- CommitPatchOperationsAction,
+ CommitPatchOperationsAction, DeletePendingJsonPatchOperationsAction,
RollbacktPatchOperationsAction,
StartTransactionPatchOperationsAction
} from './json-patch-operations.actions';
@@ -105,6 +105,13 @@ export abstract class JsonPatchOperationsService {
+ it('should start with an empty array', () => {
+ const state0 = metaTagReducer(undefined, nullAction);
+ expect(state0.tagsInUse).toEqual([]);
+ });
+
+ it('should return the current state on invalid action', () => {
+ const state0 = {
+ tagsInUse: ['foo', 'bar'],
+ };
+
+ const state1 = metaTagReducer(state0, nullAction);
+ expect(state1).toEqual(state0);
+ });
+
+ it('should add tags on AddMetaTagAction', () => {
+ const state0 = {
+ tagsInUse: ['foo'],
+ };
+
+ const state1 = metaTagReducer(state0, new AddMetaTagAction('bar'));
+ const state2 = metaTagReducer(state1, new AddMetaTagAction('baz'));
+
+ expect(state1.tagsInUse).toEqual(['foo', 'bar']);
+ expect(state2.tagsInUse).toEqual(['foo', 'bar', 'baz']);
+ });
+
+ it('should clear tags on ClearMetaTagAction', () => {
+ const state0 = {
+ tagsInUse: ['foo', 'bar'],
+ };
+
+ const state1 = metaTagReducer(state0, new ClearMetaTagAction());
+
+ expect(state1.tagsInUse).toEqual([]);
+ });
+});
diff --git a/src/app/core/metadata/meta-tag.reducer.ts b/src/app/core/metadata/meta-tag.reducer.ts
new file mode 100644
index 0000000000..0af6fb0aab
--- /dev/null
+++ b/src/app/core/metadata/meta-tag.reducer.ts
@@ -0,0 +1,38 @@
+import {
+ MetaTagAction,
+ MetaTagTypes,
+ AddMetaTagAction,
+ ClearMetaTagAction,
+} from './meta-tag.actions';
+
+export interface MetaTagState {
+ tagsInUse: string[];
+}
+
+const initialstate: MetaTagState = {
+ tagsInUse: []
+};
+
+export const metaTagReducer = (state: MetaTagState = initialstate, action: MetaTagAction): MetaTagState => {
+ switch (action.type) {
+ case MetaTagTypes.ADD: {
+ return addMetaTag(state, action as AddMetaTagAction);
+ }
+ case MetaTagTypes.CLEAR: {
+ return clearMetaTags(state, action as ClearMetaTagAction);
+ }
+ default: {
+ return state;
+ }
+ }
+};
+
+const addMetaTag = (state: MetaTagState, action: AddMetaTagAction): MetaTagState => {
+ return {
+ tagsInUse: [...state.tagsInUse, action.payload]
+ };
+};
+
+const clearMetaTags = (state: MetaTagState, action: ClearMetaTagAction): MetaTagState => {
+ return Object.assign({}, initialstate);
+};
diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts
index 18421dd489..b3404e84d5 100644
--- a/src/app/core/metadata/metadata.service.spec.ts
+++ b/src/app/core/metadata/metadata.service.spec.ts
@@ -1,82 +1,28 @@
-import { CommonModule, Location } from '@angular/common';
-import { HttpClient } from '@angular/common/http';
-import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
-import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
-import { Meta, MetaDefinition, Title } from '@angular/platform-browser';
-import { ActivatedRoute, Router } from '@angular/router';
-import { RouterTestingModule } from '@angular/router/testing';
+import { fakeAsync, tick } from '@angular/core/testing';
+import { Meta, Title } from '@angular/platform-browser';
+import { NavigationEnd, Router } from '@angular/router';
-import { Store, StoreModule } from '@ngrx/store';
-
-import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
-import { EmptyError, Observable, of } from 'rxjs';
+import { TranslateService } from '@ngx-translate/core';
+import { Observable, of } from 'rxjs';
import { RemoteData } from '../data/remote-data';
import { Item } from '../shared/item.model';
-import {
- ItemMock,
- MockBitstream1,
- MockBitstream2,
- MockBitstreamFormat1,
- MockBitstreamFormat2
-} from '../../shared/mocks/item.mock';
-import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
-import { NotificationsService } from '../../shared/notifications/notifications.service';
-import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
-import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
-import { AuthService } from '../auth/auth.service';
-import { BrowseService } from '../browse/browse.service';
-import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
-import { ObjectCacheService } from '../cache/object-cache.service';
-
-import { CoreState } from '../core.reducers';
-import { BitstreamDataService } from '../data/bitstream-data.service';
-import { BitstreamFormatDataService } from '../data/bitstream-format-data.service';
-import { CommunityDataService } from '../data/community-data.service';
-import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service';
-import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
-
-import { ItemDataService } from '../data/item-data.service';
-import { buildPaginatedList, PaginatedList } from '../data/paginated-list.model';
-import { FindListOptions } from '../data/request.models';
-import { RequestService } from '../data/request.service';
-import { BitstreamFormat } from '../shared/bitstream-format.model';
+import { ItemMock, MockBitstream1, MockBitstream3 } from '../../shared/mocks/item.mock';
+import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
+import { PaginatedList } from '../data/paginated-list.model';
import { Bitstream } from '../shared/bitstream.model';
-import { HALEndpointService } from '../shared/hal-endpoint.service';
import { MetadataValue } from '../shared/metadata.models';
-import { PageInfo } from '../shared/page-info.model';
-import { UUIDService } from '../shared/uuid.service';
import { MetadataService } from './metadata.service';
-import { environment } from '../../../environments/environment';
-import { storeModuleConfig } from '../../app.reducer';
-import { HardRedirectService } from '../services/hard-redirect.service';
-import { URLCombiner } from '../url-combiner/url-combiner';
import { RootDataService } from '../data/root-data.service';
-import { Root } from '../data/root.model';
-
-/* tslint:disable:max-classes-per-file */
-@Component({
- template: `
- `
-})
-class TestComponent {
- constructor(private metadata: MetadataService) {
- metadata.listenForRouteChange();
- }
-}
-
-@Component({ template: '' })
-class DummyItemComponent {
- constructor(private route: ActivatedRoute, private items: ItemDataService, private metadata: MetadataService) {
- this.route.params.subscribe((params) => {
- this.metadata.processRemoteData(this.items.findById(params.id));
- });
- }
-}
-
-/* tslint:enable:max-classes-per-file */
+import { Bundle } from '../shared/bundle.model';
+import { createPaginatedList } from '../../shared/testing/utils.test';
+import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
+import { DSONameService } from '../breadcrumbs/dso-name.service';
+import { HardRedirectService } from '../services/hard-redirect.service';
+import { getMockStore } from '@ngrx/store/testing';
+import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions';
describe('MetadataService', () => {
let metadataService: MetadataService;
@@ -85,188 +31,339 @@ describe('MetadataService', () => {
let title: Title;
- let store: Store;
+ let dsoNameService: DSONameService;
- let objectCacheService: ObjectCacheService;
- let requestService: RequestService;
- let uuidService: UUIDService;
- let remoteDataBuildService: RemoteDataBuildService;
- let itemDataService: ItemDataService;
- let authService: AuthService;
+ let bundleDataService;
+ let bitstreamDataService;
let rootService: RootDataService;
let translateService: TranslateService;
+ let hardRedirectService: HardRedirectService;
- let location: Location;
let router: Router;
- let fixture: ComponentFixture;
+ let store;
+
+ const initialState = { 'core': { metaTag: { tagsInUse: ['title', 'description'] }}};
- let tagStore: Map;
beforeEach(() => {
+ rootService = jasmine.createSpyObj({
+ findRoot: createSuccessfulRemoteDataObject$({ dspaceVersion: 'mock-dspace-version' })
+ });
+ bitstreamDataService = jasmine.createSpyObj({
+ findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([MockBitstream3]))
+ });
+ bundleDataService = jasmine.createSpyObj({
+ findByItemAndName: mockBundleRD$([MockBitstream3])
+ });
+ translateService = getMockTranslateService();
+ meta = jasmine.createSpyObj('meta', {
+ addTag: {},
+ removeTag: {}
+ });
+ title = jasmine.createSpyObj({
+ setTitle: {}
+ });
+ dsoNameService = jasmine.createSpyObj({
+ getName: ItemMock.firstMetadataValue('dc.title')
+ });
+ router = {
+ url: '/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357',
+ events: of(new NavigationEnd(1, '', '')),
+ routerState: {
+ root: {}
+ }
+ } as any as Router;
+ hardRedirectService = jasmine.createSpyObj( {
+ getRequestOrigin: 'https://request.org',
+ });
- store = new Store(undefined, undefined, undefined);
+ // @ts-ignore
+ store = getMockStore({ initialState });
spyOn(store, 'dispatch');
- objectCacheService = new ObjectCacheService(store, undefined);
- uuidService = new UUIDService();
- requestService = new RequestService(objectCacheService, uuidService, store, undefined);
- remoteDataBuildService = new RemoteDataBuildService(objectCacheService, undefined, requestService);
- const mockBitstreamDataService = {
- findAllByItemAndBundleName(item: Item, bundleName: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable>> {
- if (item.equals(ItemMock)) {
- return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [MockBitstream1, MockBitstream2]));
- } else {
- return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), []));
- }
- },
- };
- const mockBitstreamFormatDataService = {
- findByBitstream(bitstream: Bitstream): Observable> {
- switch (bitstream) {
- case MockBitstream1:
- return createSuccessfulRemoteDataObject$(MockBitstreamFormat1);
- break;
- case MockBitstream2:
- return createSuccessfulRemoteDataObject$(MockBitstreamFormat2);
- break;
- default:
- return createSuccessfulRemoteDataObject$(new BitstreamFormat());
- }
- }
- };
- rootService = jasmine.createSpyObj('rootService', {
- findRoot: createSuccessfulRemoteDataObject$(Object.assign(new Root(), {
- dspaceVersion: 'mock-dspace-version'
- }))
- });
-
- TestBed.configureTestingModule({
- imports: [
- CommonModule,
- StoreModule.forRoot({}, storeModuleConfig),
- TranslateModule.forRoot({
- loader: {
- provide: TranslateLoader,
- useClass: TranslateLoaderMock
- }
- }),
- RouterTestingModule.withRoutes([
- { path: 'items/:id', component: DummyItemComponent, pathMatch: 'full' },
- {
- path: 'other',
- component: DummyItemComponent,
- pathMatch: 'full',
- data: { title: 'Dummy Title', description: 'This is a dummy item component for testing!' }
- }
- ])
- ],
- declarations: [
- TestComponent,
- DummyItemComponent
- ],
- providers: [
- { provide: ObjectCacheService, useValue: objectCacheService },
- { provide: RequestService, useValue: requestService },
- { provide: RemoteDataBuildService, useValue: remoteDataBuildService },
- { provide: HALEndpointService, useValue: {} },
- { provide: AuthService, useValue: {} },
- { provide: NotificationsService, useValue: {} },
- { provide: HttpClient, useValue: {} },
- { provide: DSOChangeAnalyzer, useValue: {} },
- { provide: CommunityDataService, useValue: {} },
- { provide: DefaultChangeAnalyzer, useValue: {} },
- { provide: BitstreamFormatDataService, useValue: mockBitstreamFormatDataService },
- { provide: BitstreamDataService, useValue: mockBitstreamDataService },
- { provide: RootDataService, useValue: rootService },
- Meta,
- Title,
- // tslint:disable-next-line:no-empty
- { provide: ItemDataService, useValue: { findById: () => {} } },
- BrowseService,
- MetadataService
- ],
- schemas: [CUSTOM_ELEMENTS_SCHEMA]
- });
- meta = TestBed.inject(Meta);
- title = TestBed.inject(Title);
- itemDataService = TestBed.inject(ItemDataService);
- metadataService = TestBed.inject(MetadataService);
- authService = TestBed.inject(AuthService);
- translateService = TestBed.inject(TranslateService);
-
- router = TestBed.inject(Router);
- location = TestBed.inject(Location);
-
- fixture = TestBed.createComponent(TestComponent);
-
- tagStore = metadataService.getTagStore();
+ metadataService = new MetadataService(
+ router,
+ translateService,
+ meta,
+ title,
+ dsoNameService,
+ bundleDataService,
+ bitstreamDataService,
+ undefined,
+ rootService,
+ store,
+ hardRedirectService
+ );
});
it('items page should set meta tags', fakeAsync(() => {
- spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock));
- router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']);
+ (metadataService as any).processRouteChange({
+ data: {
+ value: {
+ dso: createSuccessfulRemoteDataObject(ItemMock),
+ }
+ }
+ });
tick();
- expect(title.getTitle()).toEqual('Test PowerPoint Document');
- expect(tagStore.get('citation_title')[0].content).toEqual('Test PowerPoint Document');
- expect(tagStore.get('citation_author')[0].content).toEqual('Doe, Jane');
- expect(tagStore.get('citation_date')[0].content).toEqual('1650-06-26');
- expect(tagStore.get('citation_issn')[0].content).toEqual('123456789');
- expect(tagStore.get('citation_language')[0].content).toEqual('en');
- expect(tagStore.get('citation_keywords')[0].content).toEqual('keyword1; keyword2; keyword3');
+ expect(title.setTitle).toHaveBeenCalledWith('Test PowerPoint Document');
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'citation_title',
+ content: 'Test PowerPoint Document'
+ });
+ expect(meta.addTag).toHaveBeenCalledWith({ property: 'citation_author', content: 'Doe, Jane' });
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'citation_publication_date',
+ content: '1650-06-26'
+ });
+ expect(meta.addTag).toHaveBeenCalledWith({ property: 'citation_issn', content: '123456789' });
+ expect(meta.addTag).toHaveBeenCalledWith({ property: 'citation_language', content: 'en' });
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'citation_keywords',
+ content: 'keyword1; keyword2; keyword3'
+ });
}));
it('items page should set meta tags as published Thesis', fakeAsync(() => {
- spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockPublisher(mockType(ItemMock, 'Thesis'))));
- router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']);
+ (metadataService as any).processRouteChange({
+ data: {
+ value: {
+ dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))),
+ }
+ }
+ });
tick();
- expect(tagStore.get('citation_dissertation_name')[0].content).toEqual('Test PowerPoint Document');
- expect(tagStore.get('citation_dissertation_institution')[0].content).toEqual('Mock Publisher');
- expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual([environment.ui.baseUrl, router.url].join(''));
- expect(tagStore.get('citation_pdf_url')[0].content).toEqual('/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download');
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'citation_dissertation_name',
+ content: 'Test PowerPoint Document'
+ });
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'citation_pdf_url',
+ content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'
+ });
}));
it('items page should set meta tags as published Technical Report', fakeAsync(() => {
- spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockPublisher(mockType(ItemMock, 'Technical Report'))));
- router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']);
- tick();
- expect(tagStore.get('citation_technical_report_institution')[0].content).toEqual('Mock Publisher');
- }));
-
- it('other navigation should add title, description and Generator', fakeAsync(() => {
- spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock));
- spyOn(translateService, 'get').and.returnValues(of('DSpace :: '), of('Dummy Title'), of('This is a dummy item component for testing!'));
- router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']);
- tick();
- expect(tagStore.size).toBeGreaterThan(0);
- router.navigate(['/other']);
- tick();
- expect(tagStore.size).toEqual(3);
- expect(title.getTitle()).toEqual('DSpace :: Dummy Title');
- expect(tagStore.get('title')[0].content).toEqual('DSpace :: Dummy Title');
- expect(tagStore.get('description')[0].content).toEqual('This is a dummy item component for testing!');
- expect(tagStore.get('Generator')[0].content).toEqual('mock-dspace-version');
- }));
-
- describe('when the item has no bitstreams', () => {
-
- beforeEach(() => {
- // this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL')
- // spyOn(MockItem, 'getFiles').and.returnValue(observableOf([]));
+ (metadataService as any).processRouteChange({
+ data: {
+ value: {
+ dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))),
+ }
+ }
});
+ tick();
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'citation_technical_report_institution',
+ content: 'Mock Publisher'
+ });
+ }));
- it('processRemoteData should not produce an EmptyError', fakeAsync(() => {
- spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock));
- spyOn(metadataService, 'processRemoteData').and.callThrough();
- router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']);
+ it('other navigation should add title and description', fakeAsync(() => {
+ (translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Dummy Title'), of('This is a dummy item component for testing!'));
+ (metadataService as any).processRouteChange({
+ data: {
+ value: {
+ title: 'Dummy Title',
+ description: 'This is a dummy item component for testing!'
+ }
+ }
+ });
+ tick();
+ expect(title.setTitle).toHaveBeenCalledWith('DSpace :: Dummy Title');
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'title',
+ content: 'DSpace :: Dummy Title'
+ });
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'description',
+ content: 'This is a dummy item component for testing!'
+ });
+ }));
+
+ describe(`listenForRouteChange`, () => {
+ it(`should call processRouteChange`, fakeAsync(() => {
+ spyOn(metadataService as any, 'processRouteChange').and.callFake(() => undefined);
+ metadataService.listenForRouteChange();
tick();
- expect(metadataService.processRemoteData).not.toThrow(new EmptyError());
+ expect((metadataService as any).processRouteChange).toHaveBeenCalled();
+ }));
+ it(`should add Generator`, fakeAsync(() => {
+ spyOn(metadataService as any, 'processRouteChange').and.callFake(() => undefined);
+ metadataService.listenForRouteChange();
+ tick();
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'Generator',
+ content: 'mock-dspace-version'
+ });
}));
-
});
- const mockRemoteData = (mockItem: Item): Observable> => {
- return createSuccessfulRemoteDataObject$(ItemMock);
- };
+ describe('citation_abstract_html_url', () => {
+ it('should use dc.identifier.uri if available', fakeAsync(() => {
+ (metadataService as any).processRouteChange({
+ data: {
+ value: {
+ dso: createSuccessfulRemoteDataObject(mockUri(ItemMock, 'https://ddg.gg')),
+ }
+ }
+ });
+ tick();
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'citation_abstract_html_url',
+ content: 'https://ddg.gg'
+ });
+ }));
+
+ it('should use current route as fallback', fakeAsync(() => {
+ (metadataService as any).processRouteChange({
+ data: {
+ value: {
+ dso: createSuccessfulRemoteDataObject(mockUri(ItemMock)),
+ }
+ }
+ });
+ tick();
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'citation_abstract_html_url',
+ content: 'https://request.org/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357'
+ });
+ }));
+ });
+
+ describe('citation_*_institution / citation_publisher', () => {
+ it('should use citation_dissertation_institution tag for dissertations', fakeAsync(() => {
+ (metadataService as any).processRouteChange({
+ data: {
+ value: {
+ dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))),
+ }
+ }
+ });
+ tick();
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'citation_dissertation_institution',
+ content: 'Mock Publisher'
+ });
+ expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_technical_report_institution' }));
+ expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_publisher' }));
+ }));
+
+ it('should use citation_tech_report_institution tag for tech reports', fakeAsync(() => {
+ (metadataService as any).processRouteChange({
+ data: {
+ value: {
+ dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))),
+ }
+ }
+ });
+ tick();
+ expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_dissertation_institution' }));
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'citation_technical_report_institution',
+ content: 'Mock Publisher'
+ });
+ expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_publisher' }));
+ }));
+
+ it('should use citation_publisher for other item types', fakeAsync(() => {
+ (metadataService as any).processRouteChange({
+ data: {
+ value: {
+ dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Some Other Type'))),
+ }
+ }
+ });
+ tick();
+ expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_dissertation_institution' }));
+ expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_technical_report_institution' }));
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'citation_publisher',
+ content: 'Mock Publisher'
+ });
+ }));
+ });
+
+ describe('citation_pdf_url', () => {
+ it('should link to primary Bitstream URL regardless of format', fakeAsync(() => {
+ (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([], MockBitstream3));
+
+ (metadataService as any).processRouteChange({
+ data: {
+ value: {
+ dso: createSuccessfulRemoteDataObject(ItemMock),
+ }
+ }
+ });
+ tick();
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'citation_pdf_url',
+ content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'
+ });
+ }));
+
+ describe('no primary Bitstream', () => {
+ it('should link to first and only Bitstream regardless of format', fakeAsync(() => {
+ (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([MockBitstream3]));
+
+ (metadataService as any).processRouteChange({
+ data: {
+ value: {
+ dso: createSuccessfulRemoteDataObject(ItemMock),
+ }
+ }
+ });
+ tick();
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'citation_pdf_url',
+ content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'
+ });
+ }));
+
+ it('should link to first Bitstream with allowed format', fakeAsync(() => {
+ const bitstreams = [MockBitstream3, MockBitstream3, MockBitstream1];
+ (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
+ (bitstreamDataService.findAllByHref as jasmine.Spy).and.returnValues(
+ ...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
+ );
+
+ (metadataService as any).processRouteChange({
+ data: {
+ value: {
+ dso: createSuccessfulRemoteDataObject(ItemMock),
+ }
+ }
+ });
+ tick();
+ expect(meta.addTag).toHaveBeenCalledWith({
+ property: 'citation_pdf_url',
+ content: 'https://request.org/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/download'
+ });
+ }));
+ });
+ });
+
+ describe('tagstore', () => {
+ beforeEach(fakeAsync(() => {
+ (metadataService as any).processRouteChange({
+ data: {
+ value: {
+ dso: createSuccessfulRemoteDataObject(ItemMock),
+ }
+ }
+ });
+ tick();
+ }));
+
+ it('should remove previous tags on route change', fakeAsync(() => {
+ expect(meta.removeTag).toHaveBeenCalledWith('property=\'title\'');
+ expect(meta.removeTag).toHaveBeenCalledWith('property=\'description\'');
+ }));
+
+ it('should clear all tags and add new ones on route change', () => {
+ expect(store.dispatch.calls.argsFor(0)).toEqual([new ClearMetaTagAction()]);
+ expect(store.dispatch.calls.argsFor(1)).toEqual([new AddMetaTagAction('title')]);
+ expect(store.dispatch.calls.argsFor(2)).toEqual([new AddMetaTagAction('description')]);
+ });
+ });
const mockType = (mockItem: Item, type: string): Item => {
const typedMockItem = Object.assign(new Item(), mockItem) as Item;
@@ -285,4 +382,30 @@ describe('MetadataService', () => {
return publishedMockItem;
};
+ const mockUri = (mockItem: Item, uri?: string): Item => {
+ const publishedMockItem = Object.assign(new Item(), mockItem) as Item;
+ publishedMockItem.metadata['dc.identifier.uri'] = [{ value: uri }] as MetadataValue[];
+ return publishedMockItem;
+ };
+
+ const mockBundleRD$ = (bitstreams: Bitstream[], primary?: Bitstream): Observable> => {
+ return createSuccessfulRemoteDataObject$(
+ Object.assign(new Bundle(), {
+ name: 'ORIGINAL',
+ bitstreams: createSuccessfulRemoteDataObject$(mockBitstreamPages$(bitstreams)[0]),
+ primaryBitstream: createSuccessfulRemoteDataObject$(primary),
+ })
+ );
+ };
+
+ const mockBitstreamPages$ = (bitstreams: Bitstream[]): PaginatedList[] => {
+ return bitstreams.map((bitstream, index) => Object.assign(createPaginatedList([bitstream]), {
+ pageInfo: {
+ totalElements: bitstreams.length, // announce multiple elements/pages
+ },
+ _links: index < bitstreams.length - 1
+ ? { next: { href: 'not empty' }} // fake link to the next bitstream page
+ : { next: { href: undefined }}, // last page has no link
+ }));
+ };
});
diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts
index 807f7a42ab..10e37b4282 100644
--- a/src/app/core/metadata/metadata.service.ts
+++ b/src/app/core/metadata/metadata.service.ts
@@ -5,12 +5,11 @@ import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
-import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
-import { catchError, distinctUntilKeyChanged, filter, first, map, take } from 'rxjs/operators';
+import { BehaviorSubject, combineLatest, EMPTY, Observable, of as observableOf } from 'rxjs';
+import { expand, filter, map, switchMap, take } from 'rxjs/operators';
-import { hasValue, isNotEmpty } from '../../shared/empty.util';
+import { hasNoValue, hasValue } from '../../shared/empty.util';
import { DSONameService } from '../breadcrumbs/dso-name.service';
-import { CacheableObject } from '../cache/object-cache.reducer';
import { BitstreamDataService } from '../data/bitstream-data.service';
import { BitstreamFormatDataService } from '../data/bitstream-format-data.service';
@@ -19,22 +18,57 @@ import { BitstreamFormat } from '../shared/bitstream-format.model';
import { Bitstream } from '../shared/bitstream.model';
import { DSpaceObject } from '../shared/dspace-object.model';
import { Item } from '../shared/item.model';
-import {
- getFirstSucceededRemoteDataPayload,
- getFirstSucceededRemoteListPayload
-} from '../shared/operators';
-import { environment } from '../../../environments/environment';
+import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../shared/operators';
import { RootDataService } from '../data/root-data.service';
import { getBitstreamDownloadRoute } from '../../app-routing-paths';
+import { BundleDataService } from '../data/bundle-data.service';
+import { followLink } from '../../shared/utils/follow-link-config.model';
+import { Bundle } from '../shared/bundle.model';
+import { PaginatedList } from '../data/paginated-list.model';
+import { URLCombiner } from '../url-combiner/url-combiner';
+import { HardRedirectService } from '../services/hard-redirect.service';
+import { MetaTagState } from './meta-tag.reducer';
+import { createSelector, select, Store } from '@ngrx/store';
+import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions';
+import { coreSelector } from '../core.selectors';
+import { CoreState } from '../core.reducers';
+
+/**
+ * The base selector function to select the metaTag section in the store
+ */
+const metaTagSelector = createSelector(
+ coreSelector,
+ (state: CoreState) => state.metaTag
+);
+
+/**
+ * Selector function to select the tags in use from the MetaTagState
+ */
+const tagsInUseSelector =
+ createSelector(
+ metaTagSelector,
+ (state: MetaTagState) => state.tagsInUse,
+ );
@Injectable()
export class MetadataService {
- private initialized: boolean;
+ private currentObject: BehaviorSubject = new BehaviorSubject(undefined);
- private tagStore: Map;
-
- private currentObject: BehaviorSubject;
+ /**
+ * When generating the citation_pdf_url meta tag for Items with more than one Bitstream (and no primary Bitstream),
+ * the first Bitstream to match one of the following MIME types is selected.
+ * See {@linkcode getFirstAllowedFormatBitstreamLink}
+ * @private
+ */
+ private readonly CITATION_PDF_URL_MIMETYPES = [
+ 'application/pdf', // .pdf
+ 'application/postscript', // .ps
+ 'application/msword', // .doc
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
+ 'application/rtf', // .rtf
+ 'application/epub+zip', // .epub
+ ];
constructor(
private router: Router,
@@ -42,21 +76,19 @@ export class MetadataService {
private meta: Meta,
private title: Title,
private dsoNameService: DSONameService,
+ private bundleDataService: BundleDataService,
private bitstreamDataService: BitstreamDataService,
private bitstreamFormatDataService: BitstreamFormatDataService,
- private rootService: RootDataService
+ private rootService: RootDataService,
+ private store: Store,
+ private hardRedirectService: HardRedirectService,
) {
- // TODO: determine what open graph meta tags are needed and whether
- // the differ per route. potentially add image based on DSpaceObject
- this.meta.addTags([
- { property: 'og:title', content: 'DSpace Angular Universal' },
- { property: 'og:description', content: 'The modern front-end for DSpace 7.' }
- ]);
- this.initialized = false;
- this.tagStore = new Map();
}
public listenForRouteChange(): void {
+ // This never changes, set it only once
+ this.setGenerator();
+
this.router.events.pipe(
filter((event) => event instanceof NavigationEnd),
map(() => this.router.routerState.root),
@@ -68,22 +100,9 @@ export class MetadataService {
});
}
- public processRemoteData(remoteData: Observable>): void {
- remoteData.pipe(map((rd: RemoteData) => rd.payload),
- filter((co: CacheableObject) => hasValue(co)),
- take(1))
- .subscribe((dspaceObject: DSpaceObject) => {
- if (!this.initialized) {
- this.initialize(dspaceObject);
- }
- this.currentObject.next(dspaceObject);
- });
- }
-
private processRouteChange(routeInfo: any): void {
- if (routeInfo.params.value.id === undefined) {
- this.clearMetaTags();
- }
+ this.clearMetaTags();
+
if (routeInfo.data.value.title) {
const titlePrefix = this.translate.get('repository.title.prefix');
const title = this.translate.get(routeInfo.data.value.title, routeInfo.data.value);
@@ -98,15 +117,10 @@ export class MetadataService {
});
}
- this.setGenerator();
- }
-
- private initialize(dspaceObject: DSpaceObject): void {
- this.currentObject = new BehaviorSubject(dspaceObject);
- this.currentObject.asObservable().pipe(distinctUntilKeyChanged('uuid')).subscribe(() => {
- this.setMetaTags();
- });
- this.initialized = true;
+ if (hasValue(routeInfo.data.value.dso) && hasValue(routeInfo.data.value.dso.payload)) {
+ this.currentObject.next(routeInfo.data.value.dso.payload);
+ this.setDSOMetaTags();
+ }
}
private getCurrentRoute(route: ActivatedRoute): ActivatedRoute {
@@ -116,16 +130,14 @@ export class MetadataService {
return route;
}
- private setMetaTags(): void {
-
- this.clearMetaTags();
+ private setDSOMetaTags(): void {
this.setTitleTag();
this.setDescriptionTag();
this.setCitationTitleTag();
this.setCitationAuthorTags();
- this.setCitationDateTag();
+ this.setCitationPublicationDateTag();
this.setCitationISSNTag();
this.setCitationISBNTag();
@@ -134,14 +146,10 @@ export class MetadataService {
this.setCitationAbstractUrlTag();
this.setCitationPdfUrlTag();
+ this.setCitationPublisherTag();
if (this.isDissertation()) {
this.setCitationDissertationNameTag();
- this.setCitationDissertationInstitutionTag();
- }
-
- if (this.isTechReport()) {
- this.setCitationTechReportInstitutionTag();
}
// this.setCitationJournalTitleTag();
@@ -176,7 +184,7 @@ export class MetadataService {
private setDescriptionTag(): void {
// TODO: truncate abstract
const value = this.getMetaTagValue('dc.description.abstract');
- this.addMetaTag('desciption', value);
+ this.addMetaTag('description', value);
}
/**
@@ -196,11 +204,11 @@ export class MetadataService {
}
/**
- * Add to the
+ * Add to the
*/
- private setCitationDateTag(): void {
+ private setCitationPublicationDateTag(): void {
const value = this.getFirstMetaTagValue(['dc.date.copyright', 'dc.date.issued', 'dc.date.available', 'dc.date.accessioned']);
- this.addMetaTag('citation_date', value);
+ this.addMetaTag('citation_publication_date', value);
}
/**
@@ -236,19 +244,17 @@ export class MetadataService {
}
/**
- * Add to the
+ * Add dc.publisher to the . The tag name depends on the item type.
*/
- private setCitationDissertationInstitutionTag(): void {
+ private setCitationPublisherTag(): void {
const value = this.getMetaTagValue('dc.publisher');
- this.addMetaTag('citation_dissertation_institution', value);
- }
-
- /**
- * Add to the
- */
- private setCitationTechReportInstitutionTag(): void {
- const value = this.getMetaTagValue('dc.publisher');
- this.addMetaTag('citation_technical_report_institution', value);
+ if (this.isDissertation()) {
+ this.addMetaTag('citation_dissertation_institution', value);
+ } else if (this.isTechReport()) {
+ this.addMetaTag('citation_technical_report_institution', value);
+ } else {
+ this.addMetaTag('citation_publisher', value);
+ }
}
/**
@@ -264,8 +270,11 @@ export class MetadataService {
*/
private setCitationAbstractUrlTag(): void {
if (this.currentObject.value instanceof Item) {
- const value = [environment.ui.baseUrl, this.router.url].join('');
- this.addMetaTag('citation_abstract_html_url', value);
+ let url = this.getMetaTagValue('dc.identifier.uri');
+ if (hasNoValue(url)) {
+ url = new URLCombiner(this.hardRedirectService.getRequestOrigin(), this.router.url).toString();
+ }
+ this.addMetaTag('citation_abstract_html_url', url);
}
}
@@ -275,35 +284,126 @@ export class MetadataService {
private setCitationPdfUrlTag(): void {
if (this.currentObject.value instanceof Item) {
const item = this.currentObject.value as Item;
- this.bitstreamDataService.findAllByItemAndBundleName(item, 'ORIGINAL')
- .pipe(
- getFirstSucceededRemoteListPayload(),
- first((files) => isNotEmpty(files)),
- catchError((error) => {
- console.debug(error.message);
- return [];
- }))
- .subscribe((bitstreams: Bitstream[]) => {
- for (const bitstream of bitstreams) {
- this.bitstreamFormatDataService.findByBitstream(bitstream).pipe(
- getFirstSucceededRemoteDataPayload()
- ).subscribe((format: BitstreamFormat) => {
- if (format.mimetype === 'application/pdf') {
- const bitstreamLink = getBitstreamDownloadRoute(bitstream);
- this.addMetaTag('citation_pdf_url', bitstreamLink);
+
+ // Retrieve the ORIGINAL bundle for the item
+ this.bundleDataService.findByItemAndName(
+ item,
+ 'ORIGINAL',
+ true,
+ true,
+ followLink('primaryBitstream'),
+ followLink('bitstreams', {}, followLink('format')),
+ ).pipe(
+ getFirstSucceededRemoteDataPayload(),
+ switchMap((bundle: Bundle) =>
+
+ // First try the primary bitstream
+ bundle.primaryBitstream.pipe(
+ getFirstCompletedRemoteData(),
+ map((rd: RemoteData) => {
+ if (hasValue(rd.payload)) {
+ return rd.payload;
+ } else {
+ return null;
}
- });
+ }),
+ // return the bundle as well so we can use it again if there's no primary bitstream
+ map((bitstream: Bitstream) => [bundle, bitstream])
+ )
+ ),
+ switchMap(([bundle, primaryBitstream]: [Bundle, Bitstream]) => {
+ if (hasValue(primaryBitstream)) {
+ // If there was a primary bitstream, emit its link
+ return [getBitstreamDownloadRoute(primaryBitstream)];
+ } else {
+ // Otherwise consider the regular bitstreams in the bundle
+ return bundle.bitstreams.pipe(
+ getFirstCompletedRemoteData(),
+ switchMap((bitstreamRd: RemoteData>) => {
+ if (hasValue(bitstreamRd.payload) && bitstreamRd.payload.totalElements === 1) {
+ // If there's only one bitstream in the bundle, emit its link
+ return [getBitstreamDownloadRoute(bitstreamRd.payload.page[0])];
+ } else {
+ // Otherwise check all bitstreams to see if one matches the format whitelist
+ return this.getFirstAllowedFormatBitstreamLink(bitstreamRd);
+ }
+ })
+ );
}
- });
+ }),
+ take(1)
+ ).subscribe((link: string) => {
+ // Use the found link to set the tag
+ this.addMetaTag(
+ 'citation_pdf_url',
+ new URLCombiner(this.hardRedirectService.getRequestOrigin(), link).toString()
+ );
+ });
}
}
+ /**
+ * For Items with more than one Bitstream (and no primary Bitstream), link to the first Bitstream with a MIME type
+ * included in {@linkcode CITATION_PDF_URL_MIMETYPES}
+ * @param bitstreamRd
+ * @private
+ */
+ private getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData>): Observable {
+ return observableOf(bitstreamRd.payload).pipe(
+ // Because there can be more than one page of bitstreams, this expand operator
+ // will retrieve them in turn. Due to the take(1) at the bottom, it will only
+ // retrieve pages until a match is found
+ expand((paginatedList: PaginatedList) => {
+ if (hasNoValue(paginatedList.next)) {
+ // If there's no next page, stop.
+ return EMPTY;
+ } else {
+ // Otherwise retrieve the next page
+ return this.bitstreamDataService.findAllByHref(
+ paginatedList.next,
+ undefined,
+ true,
+ true,
+ followLink('format')
+ ).pipe(
+ getFirstCompletedRemoteData(),
+ map((next: RemoteData>) => {
+ if (hasValue(next.payload)) {
+ return next.payload;
+ } else {
+ return EMPTY;
+ }
+ })
+ );
+ }
+ }),
+ // Return the array of bitstreams inside each paginated list
+ map((paginatedList: PaginatedList) => paginatedList.page),
+ // Emit the bitstreams in the list one at a time
+ switchMap((bitstreams: Bitstream[]) => bitstreams),
+ // Retrieve the format for each bitstream
+ switchMap((bitstream: Bitstream) => bitstream.format.pipe(
+ getFirstSucceededRemoteDataPayload(),
+ // Keep the original bitstream, because it, not the format, is what we'll need
+ // for the link at the end
+ map((format: BitstreamFormat) => [bitstream, format])
+ )),
+ // Filter out only pairs with whitelisted formats
+ filter(([, format]: [Bitstream, BitstreamFormat]) =>
+ hasValue(format) && this.CITATION_PDF_URL_MIMETYPES.includes(format.mimetype)),
+ // We only need 1
+ take(1),
+ // Emit the link of the match
+ map(([bitstream, ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream))
+ );
+ }
+
/**
* Add to the containing the current DSpace version
*/
private setGenerator(): void {
this.rootService.findRoot().pipe(getFirstSucceededRemoteDataPayload()).subscribe((root) => {
- this.addMetaTag('Generator', root.dspaceVersion);
+ this.meta.addTag({ property: 'Generator', content: root.dspaceVersion });
});
}
@@ -351,7 +451,7 @@ export class MetadataService {
if (content) {
const tag = { property, content } as MetaDefinition;
this.meta.addTag(tag);
- this.storeTag(property, tag);
+ this.storeTag(property);
}
}
@@ -361,33 +461,21 @@ export class MetadataService {
}
}
- private storeTag(key: string, tag: MetaDefinition): void {
- const tags: MetaDefinition[] = this.getTags(key);
- tags.push(tag);
- this.setTags(key, tags);
- }
-
- private getTags(key: string): MetaDefinition[] {
- let tags: MetaDefinition[] = this.tagStore.get(key);
- if (tags === undefined) {
- tags = [];
- }
- return tags;
- }
-
- private setTags(key: string, tags: MetaDefinition[]): void {
- this.tagStore.set(key, tags);
+ private storeTag(key: string): void {
+ this.store.dispatch(new AddMetaTagAction(key));
}
public clearMetaTags() {
- this.tagStore.forEach((tags: MetaDefinition[], property: string) => {
- this.meta.removeTag('property=\'' + property + '\'');
+ this.store.pipe(
+ select(tagsInUseSelector),
+ take(1)
+ ).subscribe((tagsInUse: string[]) => {
+ for (const property of tagsInUse) {
+ this.meta.removeTag('property=\'' + property + '\'');
+ }
+ this.store.dispatch(new ClearMetaTagAction());
});
- this.tagStore.clear();
}
- public getTagStore(): Map {
- return this.tagStore;
- }
}
diff --git a/src/app/core/shared/bitstream.model.ts b/src/app/core/shared/bitstream.model.ts
index 314818b482..baf2f82635 100644
--- a/src/app/core/shared/bitstream.model.ts
+++ b/src/app/core/shared/bitstream.model.ts
@@ -43,13 +43,14 @@ export class Bitstream extends DSpaceObject implements HALResource {
bundle: HALLink;
format: HALLink;
content: HALLink;
+ thumbnail: HALLink;
};
/**
* The thumbnail for this Bitstream
- * Needs to be resolved first, but isn't available as a {@link HALLink} yet
- * Use BitstreamDataService.getThumbnailFor(…) for now.
+ * Will be undefined unless the thumbnail {@link HALLink} has been resolved.
*/
+ @link(BITSTREAM, false, 'thumbnail')
thumbnail?: Observable>;
/**
diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts
index 53eb5e3ce2..d98c22225e 100644
--- a/src/app/core/shared/item.model.ts
+++ b/src/app/core/shared/item.model.ts
@@ -1,4 +1,4 @@
-import { autoserialize, autoserializeAs, deserialize, inheritSerialization } from 'cerialize';
+import { autoserialize, autoserializeAs, deserialize, deserializeAs, inheritSerialization } from 'cerialize';
import { Observable } from 'rxjs';
import { isEmpty } from '../../shared/empty.util';
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
@@ -19,6 +19,8 @@ import { ITEM } from './item.resource-type';
import { ChildHALResource } from './child-hal-resource.model';
import { Version } from './version.model';
import { VERSION } from './version.resource-type';
+import { BITSTREAM } from './bitstream.resource-type';
+import { Bitstream } from './bitstream.model';
/**
* Class representing a DSpace Item
@@ -37,7 +39,7 @@ export class Item extends DSpaceObject implements ChildHALResource {
/**
* The Date of the last modification of this Item
*/
- @deserialize
+ @deserializeAs(Date)
lastModified: Date;
/**
@@ -69,6 +71,7 @@ export class Item extends DSpaceObject implements ChildHALResource {
owningCollection: HALLink;
templateItemOf: HALLink;
version: HALLink;
+ thumbnail: HALLink;
self: HALLink;
};
@@ -100,6 +103,13 @@ export class Item extends DSpaceObject implements ChildHALResource {
@link(RELATIONSHIP, true)
relationships?: Observable>>;
+ /**
+ * The thumbnail for this Item
+ * Will be undefined unless the thumbnail {@link HALLink} has been resolved.
+ */
+ @link(BITSTREAM, false, 'thumbnail')
+ thumbnail?: Observable>;
+
/**
* Method that returns as which type of object this object should be rendered
*/
diff --git a/src/app/core/shared/search/search.service.ts b/src/app/core/shared/search/search.service.ts
index 9cb284db32..75723366bc 100644
--- a/src/app/core/shared/search/search.service.ts
+++ b/src/app/core/shared/search/search.service.ts
@@ -41,6 +41,43 @@ import { SearchConfig } from './search-filters/search-config.model';
import { PaginationService } from '../../pagination/pagination.service';
import { SearchConfigurationService } from './search-configuration.service';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
+import { DataService } from '../../data/data.service';
+import { Store } from '@ngrx/store';
+import { CoreState } from '../../core.reducers';
+import { ObjectCacheService } from '../../cache/object-cache.service';
+import { NotificationsService } from '../../../shared/notifications/notifications.service';
+import { HttpClient } from '@angular/common/http';
+import { DSOChangeAnalyzer } from '../../data/dso-change-analyzer.service';
+
+/* tslint:disable:max-classes-per-file */
+/**
+ * A class that lets us delegate some methods to DataService
+ */
+class DataServiceImpl extends DataService {
+ protected linkPath = 'discover';
+
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected store: Store,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService,
+ protected http: HttpClient,
+ protected comparator: DSOChangeAnalyzer) {
+ super();
+ }
+
+ /**
+ * Adds the embed options to the link for the request
+ * @param href The href the params are to be added to
+ * @param args params for the query string
+ * @param linksToFollow links we want to embed in query string if shouldEmbed is true
+ */
+ public addEmbedParams(href: string, args: string[], ...linksToFollow: FollowLinkConfig[]) {
+ return super.addEmbedParams(href, args, ...linksToFollow);
+ }
+}
/**
* Service that performs all general actions that have to do with the search page
@@ -78,6 +115,11 @@ export class SearchService implements OnDestroy {
*/
private sub;
+ /**
+ * Instance of DataServiceImpl that lets us delegate some methods to DataService
+ */
+ private searchDataService: DataServiceImpl;
+
constructor(private router: Router,
private routeService: RouteService,
protected requestService: RequestService,
@@ -89,6 +131,16 @@ export class SearchService implements OnDestroy {
private paginationService: PaginationService,
private searchConfigurationService: SearchConfigurationService
) {
+ this.searchDataService = new DataServiceImpl(
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined
+ );
}
/**
@@ -131,7 +183,17 @@ export class SearchService implements OnDestroy {
search(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> {
const href$ = this.getEndpoint(searchOptions);
- href$.pipe(take(1)).subscribe((url: string) => {
+ href$.pipe(
+ take(1),
+ map((href: string) => {
+ const args = this.searchDataService.addEmbedParams(href, [], ...linksToFollow);
+ if (isNotEmpty(args)) {
+ return new URLCombiner(href, `?${args.join('&')}`).toString();
+ } else {
+ return href;
+ }
+ })
+ ).subscribe((url: string) => {
const request = new this.request(this.requestService.generateRequestId(), url);
const getResponseParserFn: () => GenericConstructor = () => {
@@ -152,7 +214,7 @@ export class SearchService implements OnDestroy {
);
return this.directlyAttachIndexableObjects(sqr$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
- }
+ }
/**
* Method to retrieve request entries for search results from the server
@@ -399,9 +461,9 @@ export class SearchService implements OnDestroy {
let pageParams = { page: 1 };
const queryParams = { view: viewMode };
if (viewMode === ViewMode.DetailedListElement) {
- pageParams = Object.assign(pageParams, {pageSize: 1});
+ pageParams = Object.assign(pageParams, { pageSize: 1 });
} else if (config.pageSize === 1) {
- pageParams = Object.assign(pageParams, {pageSize: 10});
+ pageParams = Object.assign(pageParams, { pageSize: 10 });
}
this.paginationService.updateRouteWithUrl(this.searchConfigurationService.paginationID, hasValue(searchLinkParts) ? searchLinkParts : [this.getSearchLink()], pageParams, queryParams);
});
@@ -413,7 +475,7 @@ export class SearchService implements OnDestroy {
* @param {string} configurationName the name of the configuration
* @returns {Observable>} The found configuration
*/
- getSearchConfigurationFor(scope?: string, configurationName?: string ): Observable> {
+ getSearchConfigurationFor(scope?: string, configurationName?: string): Observable> {
const href$ = this.halService.getEndpoint(this.configurationLinkPath).pipe(
map((url: string) => this.getConfigUrl(url, scope, configurationName)),
);
diff --git a/src/app/core/shared/sequence.service.spec.ts b/src/app/core/shared/sequence.service.spec.ts
new file mode 100644
index 0000000000..e48ad3efcc
--- /dev/null
+++ b/src/app/core/shared/sequence.service.spec.ts
@@ -0,0 +1,22 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+import { SequenceService } from './sequence.service';
+
+let service: SequenceService;
+
+describe('SequenceService', () => {
+ beforeEach(() => {
+ service = new SequenceService();
+ });
+
+ it('should return sequential numbers on next(), starting with 1', () => {
+ const NUMBERS = [1,2,3,4,5];
+ const sequence = NUMBERS.map(() => service.next());
+ expect(sequence).toEqual(NUMBERS);
+ });
+});
diff --git a/src/app/core/shared/sequence.service.ts b/src/app/core/shared/sequence.service.ts
new file mode 100644
index 0000000000..2340ffb259
--- /dev/null
+++ b/src/app/core/shared/sequence.service.ts
@@ -0,0 +1,24 @@
+/**
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+import { Injectable } from '@angular/core';
+
+@Injectable()
+/**
+ * Provides unique sequential numbers
+ */
+export class SequenceService {
+ private value: number;
+
+ constructor() {
+ this.value = 0;
+ }
+
+ public next(): number {
+ return ++this.value;
+ }
+}
diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts
index d82ef01087..da58512441 100644
--- a/src/app/core/submission/vocabularies/vocabulary.service.ts
+++ b/src/app/core/submission/vocabularies/vocabulary.service.ts
@@ -173,7 +173,7 @@ export class VocabularyService {
);
// TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved
- return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe(
+ return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((vocabulary: Vocabulary) => vocabulary.entries),
);
@@ -200,7 +200,7 @@ export class VocabularyService {
);
// TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved
- return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe(
+ return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((vocabulary: Vocabulary) => vocabulary.entries),
);
@@ -249,7 +249,7 @@ export class VocabularyService {
);
// TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved
- return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe(
+ return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((vocabulary: Vocabulary) => vocabulary.entries),
getFirstSucceededRemoteListPayload(),
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html
index df6c9e60c0..028876b3d0 100644
--- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html
+++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html
@@ -8,14 +8,14 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width">
-
-
+
+
-
-
+
+
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html
index cdc19b7f14..65ff75a731 100644
--- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html
+++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html
@@ -8,14 +8,14 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width">
-
-
+
+
-
-
+
+
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html
index bacd657663..0c5824c6d6 100644
--- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html
+++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html
@@ -8,14 +8,14 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width">
-
-
+
+
-
-
+
+
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html
index 5749c5797d..5847be7dd2 100644
--- a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html
+++ b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html
@@ -8,8 +8,8 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html
index 005fa9cc83..680a9909bc 100644
--- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html
+++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html
@@ -8,14 +8,14 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width">
-
-
+
+
-
-
+
+
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html
index e84e8c49d0..204f8fc8cb 100644
--- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html
+++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html
@@ -8,14 +8,14 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width">
-
-
+
+
-
-
+
+
diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html
index 822d4858ce..c9ea8fb549 100644
--- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html
+++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html
@@ -8,8 +8,13 @@
-
-
+
+
+
-
-
+
+
+
-
-
+
+
+
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html
index 063e1393cc..13787bb925 100644
--- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html
+++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html
@@ -1,6 +1,6 @@
-
+
{
- return this.bitstreamDataService.getThumbnailFor(this.dso).pipe(
- getFirstSucceededRemoteDataPayload()
- );
- }
}
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html
index e177b2b561..87a422e7db 100644
--- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html
+++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html
@@ -21,4 +21,4 @@
-
\ No newline at end of file
+
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts
index 64cf73cfb9..13de40e015 100644
--- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts
+++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts
@@ -1,8 +1,5 @@
import { Component, OnInit } from '@angular/core';
-import { Observable } from 'rxjs';
import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
-import { Bitstream } from '../../../../../core/shared/bitstream.model';
-import { getFirstSucceededRemoteDataPayload } from '../../../../../core/shared/operators';
import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
@@ -108,11 +105,4 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu
modalComp.value = value;
return modalRef.result;
}
-
- // TODO refactor to return RemoteData, and thumbnail template to deal with loading
- getThumbnail(): Observable
{
- return this.bitstreamDataService.getThumbnailFor(this.dso).pipe(
- getFirstSucceededRemoteDataPayload()
- );
- }
}
diff --git a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts
index ad3cf9f9fa..8579becc83 100644
--- a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts
+++ b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts
@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
-import { Observable } from 'rxjs';
+import { BehaviorSubject, Observable } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
import { RemoteData } from '../../../core/data/remote-data';
import { first, map } from 'rxjs/operators';
@@ -29,6 +29,12 @@ export class DeleteComColPageComponent i
*/
public dsoRD$: Observable>;
+ /**
+ * A boolean representing if a delete operation is pending
+ * @type {BehaviorSubject}
+ */
+ public processing$: BehaviorSubject = new BehaviorSubject(false);
+
public constructor(
protected dsoDataService: ComColDataService,
protected router: Router,
@@ -48,6 +54,7 @@ export class DeleteComColPageComponent i
* Deletes an existing DSO and redirects to the home page afterwards, showing a notification that states whether or not the deletion was successful
*/
onConfirm(dso: TDomain) {
+ this.processing$.next(true);
this.dsoDataService.delete(dso.id)
.pipe(getFirstCompletedRemoteData())
.subscribe((response: RemoteData) => {
diff --git a/src/app/shared/date.util.ts b/src/app/shared/date.util.ts
index 063820784c..44afdd10a4 100644
--- a/src/app/shared/date.util.ts
+++ b/src/app/shared/date.util.ts
@@ -3,7 +3,7 @@ import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap';
import { isObject } from 'lodash';
import * as moment from 'moment';
-import { isNull } from './empty.util';
+import { isNull, isUndefined } from './empty.util';
/**
* Returns true if the passed value is a NgbDateStruct.
@@ -27,8 +27,9 @@ export function isNgbDateStruct(value: object): boolean {
* @return string
* the formatted date
*/
-export function dateToISOFormat(date: Date | NgbDateStruct): string {
- const dateObj: Date = (date instanceof Date) ? date : ngbDateStructToDate(date);
+export function dateToISOFormat(date: Date | NgbDateStruct | string): string {
+ const dateObj: Date = (date instanceof Date) ? date :
+ ((typeof date === 'string') ? ngbDateStructToDate(stringToNgbDateStruct(date)) : ngbDateStructToDate(date));
let year = dateObj.getFullYear().toString();
let month = (dateObj.getMonth() + 1).toString();
@@ -80,7 +81,7 @@ export function stringToNgbDateStruct(date: string): NgbDateStruct {
* the NgbDateStruct object
*/
export function dateToNgbDateStruct(date?: Date): NgbDateStruct {
- if (isNull(date)) {
+ if (isNull(date) || isUndefined(date)) {
date = new Date();
}
diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html
index 122f37b031..ab2ea6cd8b 100644
--- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html
+++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html
@@ -14,12 +14,12 @@
[infiniteScrollContainer]="'.scrollable-menu'"
[fromRoot]="true"
(scrolled)="onScrollDown()">
-
+
+ *ngIf="(listEntries$ | async).length == 0">
{{'dso-selector.no-results' | translate: { type: typesString } }}
- {
});
describe('populating listEntries', () => {
- it('should not be empty', () => {
- expect(component.listEntries.length).toBeGreaterThan(0);
+ 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', () => {
- expect(component.listEntries).toEqual([searchResult, ...firstPageResults]);
+ it('should contain a combination of the current DSO and first page results', (done) => {
+ component.listEntries$.subscribe((listEntries) => {
+ expect(listEntries).toEqual([searchResult, ...firstPageResults]);
+ done();
+ });
});
describe('when current page increases', () => {
@@ -105,8 +111,11 @@ describe('DSOSelectorComponent', () => {
component.currentPage$.next(2);
});
- it('should contain a combination of the current DSO, as well as first and second page results', () => {
- expect(component.listEntries).toEqual([searchResult, ...firstPageResults, ...nextPageResults]);
+ it('should contain a combination of the current DSO, as well as first and second page results', (done) => {
+ component.listEntries$.subscribe((listEntries) => {
+ expect(listEntries).toEqual([searchResult, ...firstPageResults, ...nextPageResults]);
+ done();
+ });
});
});
});
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 c62e0df763..ebd9f24b61 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
@@ -81,7 +81,7 @@ export class DSOSelectorComponent implements OnInit, OnDestroy {
/**
* List with search results of DSpace objects for the current query
*/
- listEntries: SearchResult[] = null;
+ listEntries$: BehaviorSubject[]> = new BehaviorSubject(null);
/**
* The current page to load
@@ -160,7 +160,7 @@ export class DSOSelectorComponent implements OnInit, OnDestroy {
this.loading = true;
if (page === 1) {
// The first page is loading, this means we should reset the list instead of adding to it
- this.listEntries = null;
+ this.listEntries$.next(null);
}
return this.search(query, page).pipe(
map((rd) => {
@@ -181,15 +181,16 @@ export class DSOSelectorComponent implements OnInit, OnDestroy {
).subscribe((rd) => {
this.loading = false;
if (rd.hasSucceeded) {
- if (hasNoValue(this.listEntries)) {
- this.listEntries = rd.payload.page;
+ const currentEntries = this.listEntries$.getValue();
+ if (hasNoValue(currentEntries)) {
+ this.listEntries$.next(rd.payload.page);
} else {
- this.listEntries.push(...rd.payload.page);
+ this.listEntries$.next([...currentEntries, ...rd.payload.page]);
}
// Check if there are more pages available after the current one
- this.hasNextPage = rd.payload.totalElements > this.listEntries.length;
+ this.hasNextPage = rd.payload.totalElements > this.listEntries$.getValue().length;
} else {
- this.listEntries = null;
+ this.listEntries$.next(null);
this.hasNextPage = false;
}
}));
diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html
index f1843da5c6..497502d586 100644
--- a/src/app/shared/file-download-link/file-download-link.component.html
+++ b/src/app/shared/file-download-link/file-download-link.component.html
@@ -1,5 +1,6 @@
-
-
+
+
+
diff --git a/src/app/shared/file-download-link/file-download-link.component.ts b/src/app/shared/file-download-link/file-download-link.component.ts
index 4423b6f5b7..b415e1e701 100644
--- a/src/app/shared/file-download-link/file-download-link.component.ts
+++ b/src/app/shared/file-download-link/file-download-link.component.ts
@@ -18,6 +18,17 @@ export class FileDownloadLinkComponent implements OnInit {
* Optional bitstream instead of href and file name
*/
@Input() bitstream: Bitstream;
+
+ /**
+ * Additional css classes to apply to link
+ */
+ @Input() cssClasses = '';
+
+ /**
+ * A boolean representing if link is shown in same tab or in a new one.
+ */
+ @Input() isBlank = false;
+
bitstreamPath: string;
ngOnInit() {
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker-inline/dynamic-date-picker-inline.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker-inline/dynamic-date-picker-inline.component.html
index 103e3aac23..9e4e75c7d8 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker-inline/dynamic-date-picker-inline.component.html
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker-inline/dynamic-date-picker-inline.component.html
@@ -19,6 +19,7 @@
[startDate]="model.focusedDate"
(blur)="onBlur($event)"
(dateSelect)="onChange($event)"
+ (change)="onChange($event)"
(focus)="onFocus($event)">
diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html
index 39ccda360f..de24880b3b 100644
--- a/src/app/shared/form/form.component.html
+++ b/src/app/shared/form/form.component.html
@@ -48,7 +48,7 @@
-
+
diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts
index 42469ddba2..8d75d7f13a 100644
--- a/src/app/shared/form/form.component.ts
+++ b/src/app/shared/form/form.component.ts
@@ -309,9 +309,16 @@ export class FormComponent implements OnDestroy, OnInit {
removeItem($event, arrayContext: DynamicFormArrayModel, index: number): void {
const formArrayControl = this.formGroup.get(this.formBuilderService.getPath(arrayContext)) as FormArray;
const event = this.getEvent($event, arrayContext, index, 'remove');
+ if (this.formBuilderService.isQualdropGroup(event.model as DynamicFormControlModel)) {
+ // In case of qualdrop value remove event must be dispatched before removing the control from array
+ this.removeArrayItem.emit(event);
+ }
this.formBuilderService.removeFormArrayGroup(index, formArrayControl, arrayContext);
this.formService.changeForm(this.formId, this.formModel);
- this.removeArrayItem.emit(event);
+ if (!this.formBuilderService.isQualdropGroup(event.model as DynamicFormControlModel)) {
+ // dispatch remove event for any field type except for qualdrop value
+ this.removeArrayItem.emit(event);
+ }
}
insertItem($event, arrayContext: DynamicFormArrayModel, index: number): void {
diff --git a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html
index 7a9481f2f1..886c8f31c0 100644
--- a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html
+++ b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html
@@ -3,13 +3,27 @@
(keydown.arrowdown)="shiftFocusDown($event)"
(keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()"
(dsClickOutside)="close();">
-
-
+
+
+
+ {{label}}
+
+
+
+
+
+
+
+
+
+ {{'search.filters.search.submit' | translate}}
+
-
\ No newline at end of file
+
diff --git a/src/app/shared/input-suggestions/input-suggestions.component.ts b/src/app/shared/input-suggestions/input-suggestions.component.ts
index c48dcfb831..7b5c9f34f2 100644
--- a/src/app/shared/input-suggestions/input-suggestions.component.ts
+++ b/src/app/shared/input-suggestions/input-suggestions.component.ts
@@ -53,6 +53,11 @@ export class InputSuggestionsComponent implements ControlValueAccessor, OnChange
*/
@Input() valid = true;
+ /**
+ * Label for the input field. Used for screen readers.
+ */
+ @Input() label? = '';
+
/**
* Output for when the form is submitted
*/
diff --git a/src/app/shared/mocks/item.mock.ts b/src/app/shared/mocks/item.mock.ts
index 945b0f7816..10eab2da00 100644
--- a/src/app/shared/mocks/item.mock.ts
+++ b/src/app/shared/mocks/item.mock.ts
@@ -5,6 +5,7 @@ import { Bitstream } from '../../core/shared/bitstream.model';
import { Item } from '../../core/shared/item.model';
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
import { createPaginatedList } from '../testing/utils.test';
+import { Bundle } from '../../core/shared/bundle.model';
export const MockBitstreamFormat1: BitstreamFormat = Object.assign(new BitstreamFormat(), {
shortDescription: 'Microsoft Word XML',
@@ -34,11 +35,25 @@ export const MockBitstreamFormat2: BitstreamFormat = Object.assign(new Bitstream
}
});
+export const MockBitstreamFormat3: BitstreamFormat = Object.assign(new BitstreamFormat(), {
+ shortDescription: 'Binary',
+ description: 'Some scary unknown binary file',
+ mimetype: 'application/octet-stream',
+ supportLevel: 0,
+ internal: false,
+ extensions: null,
+ _links:{
+ self: {
+ href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/17'
+ }
+ }
+});
+
export const MockBitstream1: Bitstream = Object.assign(new Bitstream(),
{
sizeBytes: 10201,
content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content',
- format: observableOf(MockBitstreamFormat1),
+ format: createSuccessfulRemoteDataObject$(MockBitstreamFormat1),
bundleName: 'ORIGINAL',
_links:{
self: {
@@ -61,7 +76,7 @@ export const MockBitstream1: Bitstream = Object.assign(new Bitstream(),
export const MockBitstream2: Bitstream = Object.assign(new Bitstream(), {
sizeBytes: 31302,
content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/content',
- format: observableOf(MockBitstreamFormat2),
+ format: createSuccessfulRemoteDataObject$(MockBitstreamFormat2),
bundleName: 'ORIGINAL',
id: '99b00f3c-1cc6-4689-8158-91965bee6b28',
uuid: '99b00f3c-1cc6-4689-8158-91965bee6b28',
@@ -82,6 +97,68 @@ export const MockBitstream2: Bitstream = Object.assign(new Bitstream(), {
}
});
+export const MockBitstream3: Bitstream = Object.assign(new Bitstream(), {
+ sizeBytes: 4975123,
+ content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/content',
+ format: createSuccessfulRemoteDataObject$(MockBitstreamFormat3),
+ bundleName: 'ORIGINAL',
+ id: '4db100c1-e1f5-4055-9404-9bc3e2d15f29',
+ uuid: '4db100c1-e1f5-4055-9404-9bc3e2d15f29',
+ type: 'bitstream',
+ _links: {
+ self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29' },
+ content: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/content' },
+ format: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/17' },
+ bundle: { href: '' }
+ },
+ metadata: {
+ 'dc.title': [
+ {
+ language: null,
+ value: 'scary'
+ }
+ ]
+ }
+});
+
+export const MockOriginalBundle: Bundle = Object.assign(new Bundle(), {
+ name: 'ORIGINAL',
+ primaryBitstream: createSuccessfulRemoteDataObject$(MockBitstream2),
+ bitstreams: observableOf(Object.assign({
+ _links: {
+ self: {
+ href: 'dspace-angular://aggregated/object/1507836003548',
+ }
+ },
+ requestPending: false,
+ responsePending: false,
+ isSuccessful: true,
+ errorMessage: '',
+ state: '',
+ error: undefined,
+ isRequestPending: false,
+ isResponsePending: false,
+ isLoading: false,
+ hasFailed: false,
+ hasSucceeded: true,
+ statusCode: '202',
+ pageInfo: {},
+ payload: {
+ pageInfo: {
+ elementsPerPage: 20,
+ totalElements: 3,
+ totalPages: 1,
+ currentPage: 2
+ },
+ page: [
+ MockBitstream1,
+ MockBitstream2
+ ]
+ }
+ }))
+});
+
+
/* tslint:disable:no-shadowed-variable */
export const ItemMock: Item = Object.assign(new Item(), {
handle: '10673/6',
@@ -90,41 +167,7 @@ export const ItemMock: Item = Object.assign(new Item(), {
isDiscoverable: true,
isWithdrawn: false,
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([
- {
- name: 'ORIGINAL',
- bitstreams: observableOf(Object.assign({
- _links: {
- self: {
- href: 'dspace-angular://aggregated/object/1507836003548',
- }
- },
- requestPending: false,
- responsePending: false,
- isSuccessful: true,
- errorMessage: '',
- state: '',
- error: undefined,
- isRequestPending: false,
- isResponsePending: false,
- isLoading: false,
- hasFailed: false,
- hasSucceeded: true,
- statusCode: '202',
- pageInfo: {},
- payload: {
- pageInfo: {
- elementsPerPage: 20,
- totalElements: 3,
- totalPages: 1,
- currentPage: 2
- },
- page: [
- MockBitstream1,
- MockBitstream2
- ]
- }
- }))
- }
+ MockOriginalBundle,
])),
_links:{
self: {
diff --git a/src/app/shared/mocks/submission.mock.ts b/src/app/shared/mocks/submission.mock.ts
index 1ee097af71..eaebb38df8 100644
--- a/src/app/shared/mocks/submission.mock.ts
+++ b/src/app/shared/mocks/submission.mock.ts
@@ -1519,83 +1519,87 @@ export const mockFileFormData = {
},
accessConditions: [
{
- name: [
- {
- value: 'openaccess',
- language: null,
- authority: null,
- display: 'openaccess',
- confidence: -1,
- place: 0,
- otherInformation: null
- }
- ],
- }
- ,
+ accessConditionGroup: {
+ name: [
+ {
+ value: 'openaccess',
+ language: null,
+ authority: null,
+ display: 'openaccess',
+ confidence: -1,
+ place: 0,
+ otherInformation: null
+ }
+ ],
+ },
+ },
{
- name: [
- {
- value: 'lease',
- language: null,
- authority: null,
- display: 'lease',
- confidence: -1,
- place: 0,
- otherInformation: null
- }
- ],
- endDate: [
- {
- value: {
- year: 2019,
- month: 1,
- day: 16
- },
- language: null,
- authority: null,
- display: {
- year: 2019,
- month: 1,
- day: 16
- },
- confidence: -1,
- place: 0,
- otherInformation: null
- }
- ],
- }
- ,
+ accessConditionGroup:{
+ name: [
+ {
+ value: 'lease',
+ language: null,
+ authority: null,
+ display: 'lease',
+ confidence: -1,
+ place: 0,
+ otherInformation: null
+ }
+ ],
+ endDate: [
+ {
+ value: {
+ year: 2019,
+ month: 1,
+ day: 16
+ },
+ language: null,
+ authority: null,
+ display: {
+ year: 2019,
+ month: 1,
+ day: 16
+ },
+ confidence: -1,
+ place: 0,
+ otherInformation: null
+ }
+ ],
+ }
+ },
{
- name: [
- {
- value: 'embargo',
- language: null,
- authority: null,
- display: 'lease',
- confidence: -1,
- place: 0,
- otherInformation: null
- }
- ],
- startDate: [
- {
- value: {
- year: 2019,
- month: 1,
- day: 16
- },
- language: null,
- authority: null,
- display: {
- year: 2019,
- month: 1,
- day: 16
- },
- confidence: -1,
- place: 0,
- otherInformation: null
- }
- ],
+ accessConditionGroup: {
+ name: [
+ {
+ value: 'embargo',
+ language: null,
+ authority: null,
+ display: 'lease',
+ confidence: -1,
+ place: 0,
+ otherInformation: null
+ }
+ ],
+ startDate: [
+ {
+ value: {
+ year: 2019,
+ month: 1,
+ day: 16
+ },
+ language: null,
+ authority: null,
+ display: {
+ year: 2019,
+ month: 1,
+ day: 16
+ },
+ confidence: -1,
+ place: 0,
+ otherInformation: null
+ }
+ ],
+ }
}
]
};
diff --git a/src/app/shared/mocks/translate.service.mock.ts b/src/app/shared/mocks/translate.service.mock.ts
index 0bc172b408..38b088e50f 100644
--- a/src/app/shared/mocks/translate.service.mock.ts
+++ b/src/app/shared/mocks/translate.service.mock.ts
@@ -3,6 +3,7 @@ import { TranslateService } from '@ngx-translate/core';
export function getMockTranslateService(): TranslateService {
return jasmine.createSpyObj('translateService', {
get: jasmine.createSpy('get'),
+ use: jasmine.createSpy('use'),
instant: jasmine.createSpy('instant'),
setDefaultLang: jasmine.createSpy('setDefaultLang')
});
diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts
index 3442b044a2..458272c606 100644
--- a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts
+++ b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts
@@ -1,5 +1,5 @@
-import { waitForAsync, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
-import { ChangeDetectionStrategy, ComponentFactoryResolver, NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ListableObjectComponentLoaderComponent } from './listable-object-component-loader.component';
import { ListableObject } from '../listable-object.model';
import { GenericConstructor } from '../../../../core/shared/generic-constructor';
@@ -117,17 +117,33 @@ describe('ListableObjectComponentLoaderComponent', () => {
});
describe('When a reloadedObject is emitted', () => {
+ let listableComponent;
+ let reloadedObject: any;
- it('should re-instantiate the listable component ', fakeAsync(() => {
+ beforeEach(() => {
+ spyOn((comp as any), 'connectInputsAndOutputs').and.returnValue(null);
+ spyOn((comp as any).contentChange, 'emit').and.returnValue(null);
- spyOn((comp as any), 'instantiateComponent').and.returnValue(null);
+ listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance;
+ reloadedObject = 'object';
+ });
+
+ it('should pass it on connectInputsAndOutputs', fakeAsync(() => {
+ expect((comp as any).connectInputsAndOutputs).not.toHaveBeenCalled();
- const listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance;
- const reloadedObject: any = 'object';
(listableComponent as any).reloadedObject.emit(reloadedObject);
tick();
- expect((comp as any).instantiateComponent).toHaveBeenCalledWith(reloadedObject);
+ expect((comp as any).connectInputsAndOutputs).toHaveBeenCalled();
+ }));
+
+ it('should re-emit it as a contentChange', fakeAsync(() => {
+ expect((comp as any).contentChange.emit).not.toHaveBeenCalled();
+
+ (listableComponent as any).reloadedObject.emit(reloadedObject);
+ tick();
+
+ expect((comp as any).contentChange.emit).toHaveBeenCalledWith(reloadedObject);
}));
});
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 30ad91c1e2..4c6206cb43 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
@@ -3,10 +3,14 @@ import {
ComponentFactoryResolver,
ElementRef,
Input,
- OnDestroy, OnInit,
- Output, ViewChild
-,
- EventEmitter
+ OnDestroy,
+ OnInit,
+ Output,
+ ViewChild,
+ EventEmitter,
+ SimpleChanges,
+ OnChanges,
+ ComponentRef
} from '@angular/core';
import { ListableObject } from '../listable-object.model';
import { ViewMode } from '../../../../core/shared/view-mode.model';
@@ -15,7 +19,7 @@ import { getListableObjectComponent } from './listable-object.decorator';
import { GenericConstructor } from '../../../../core/shared/generic-constructor';
import { ListableObjectDirective } from './listable-object.directive';
import { CollectionElementLinkType } from '../../collection-element-link.type';
-import { hasValue } from '../../../empty.util';
+import { hasValue, isNotEmpty } from '../../../empty.util';
import { Subscription } from 'rxjs/internal/Subscription';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { take } from 'rxjs/operators';
@@ -29,7 +33,7 @@ import { ThemeService } from '../../../theme-support/theme.service';
/**
* Component for determining what component to use depending on the item's entity type (dspace.entity.type)
*/
-export class ListableObjectComponentLoaderComponent implements OnInit, OnDestroy {
+export class ListableObjectComponentLoaderComponent implements OnInit, OnChanges, OnDestroy {
/**
* The item or metadata to determine the component for
*/
@@ -107,6 +111,25 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnDestroy
*/
protected subs: Subscription[] = [];
+ /**
+ * The reference to the dynamic component
+ */
+ protected compRef: ComponentRef
;
+
+ /**
+ * The list of input and output names for the dynamic component
+ */
+ protected inAndOutputNames: string[] = [
+ 'object',
+ 'index',
+ 'linkType',
+ 'listID',
+ 'showLabel',
+ 'context',
+ 'viewMode',
+ 'value',
+ ];
+
constructor(
private componentFactoryResolver: ComponentFactoryResolver,
private themeService: ThemeService
@@ -120,6 +143,15 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnDestroy
this.instantiateComponent(this.object);
}
+ /**
+ * Whenever the inputs change, update the inputs of the dynamic component
+ */
+ ngOnChanges(changes: SimpleChanges): void {
+ if (this.inAndOutputNames.some((name: any) => hasValue(changes[name]))) {
+ this.connectInputsAndOutputs();
+ }
+ }
+
ngOnDestroy() {
this.subs
.filter((subscription) => hasValue(subscription))
@@ -137,28 +169,22 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnDestroy
const viewContainerRef = this.listableObjectDirective.viewContainerRef;
viewContainerRef.clear();
- const componentRef = viewContainerRef.createComponent(
+ this.compRef = viewContainerRef.createComponent(
componentFactory,
0,
undefined,
[
[this.badges.nativeElement],
]);
- (componentRef.instance as any).object = object;
- (componentRef.instance as any).index = this.index;
- (componentRef.instance as any).linkType = this.linkType;
- (componentRef.instance as any).listID = this.listID;
- (componentRef.instance as any).showLabel = this.showLabel;
- (componentRef.instance as any).context = this.context;
- (componentRef.instance as any).viewMode = this.viewMode;
- (componentRef.instance as any).value = this.value;
- if ((componentRef.instance as any).reloadedObject) {
- (componentRef.instance as any).reloadedObject.pipe(take(1)).subscribe((reloadedObject: DSpaceObject) => {
+ this.connectInputsAndOutputs();
+
+ if ((this.compRef.instance as any).reloadedObject) {
+ (this.compRef.instance as any).reloadedObject.pipe(take(1)).subscribe((reloadedObject: DSpaceObject) => {
if (reloadedObject) {
- componentRef.destroy();
+ this.compRef.destroy();
this.object = reloadedObject;
- this.instantiateComponent(reloadedObject);
+ this.connectInputsAndOutputs();
this.contentChange.emit(reloadedObject);
}
});
@@ -187,4 +213,17 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnDestroy
context: Context): GenericConstructor {
return getListableObjectComponent(renderTypes, viewMode, context, this.themeService.getThemeName());
}
+
+ /**
+ * Connect the in and outputs of this component to the dynamic component,
+ * to ensure they're in sync
+ */
+ protected connectInputsAndOutputs(): void {
+ if (isNotEmpty(this.inAndOutputNames) && hasValue(this.compRef) && hasValue(this.compRef.instance)) {
+ this.inAndOutputNames.forEach((name: any) => {
+ this.compRef.instance[name] = this[name];
+ });
+ }
+ }
+
}
diff --git a/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.html b/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.html
index 47674025ca..1e8524e5ec 100644
--- a/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.html
+++ b/src/app/shared/object-collection/shared/mydspace-item-submitter/item-submitter.component.html
@@ -1,3 +1,3 @@
- {{'submission.workflow.tasks.generic.submitter' | translate}} : {{(submitter$ | async)?.name}}
+ {{'submission.workflow.tasks.generic.submitter' | translate}} : {{(submitter$ | async)?.name}}
diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts
index b729307443..74411d2341 100644
--- a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts
+++ b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts
@@ -49,8 +49,8 @@ export class ClaimedTaskSearchResultDetailElementComponent extends SearchResultD
*/
ngOnInit() {
super.ngOnInit();
- this.linkService.resolveLinks(this.dso, followLink('workflowitem', null, true, true, true,
- followLink('item', null, true, true, true, followLink('bundles')),
+ this.linkService.resolveLinks(this.dso, followLink('workflowitem', {},
+ followLink('item', {}, followLink('bundles')),
followLink('submitter')
), followLink('action'));
this.workflowitemRD$ = this.dso.workflowitem as Observable>;
diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html
index cbc3f9ccfb..61e2955deb 100644
--- a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html
+++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html
@@ -9,8 +9,8 @@
-
-
+
+
diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.spec.ts
index 2b38b58598..07acf3ea75 100644
--- a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.spec.ts
+++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.spec.ts
@@ -126,13 +126,6 @@ describe('ItemDetailPreviewComponent', () => {
}));
- it('should get item thumbnail', (done) => {
- component.getThumbnail().subscribe((thumbnail) => {
- expect(thumbnail).toBeDefined();
- done();
- });
- });
-
it('should get item bitstreams', (done) => {
component.getFiles().subscribe((bitstreams) => {
expect(bitstreams).toBeDefined();
diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.ts
index a4dc0a1d3d..92c1afcb59 100644
--- a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.ts
+++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.ts
@@ -5,10 +5,7 @@ import { first } from 'rxjs/operators';
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
import { Item } from '../../../../core/shared/item.model';
-import {
- getFirstSucceededRemoteDataPayload,
- getFirstSucceededRemoteListPayload
-} from '../../../../core/shared/operators';
+import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators';
import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type';
import { fadeInOut } from '../../../animations/fade';
import { Bitstream } from '../../../../core/shared/bitstream.model';
@@ -57,11 +54,6 @@ export class ItemDetailPreviewComponent {
*/
public separator = ', ';
- /**
- * The item's thumbnail
- */
- public thumbnail$: Observable;
-
/**
* Initialize instance variables
*
@@ -86,13 +78,6 @@ export class ItemDetailPreviewComponent {
});
}
- // TODO refactor this method to return RemoteData, and the template to deal with loading and errors
- public getThumbnail(): Observable {
- return this.bitstreamDataService.getThumbnailFor(this.item).pipe(
- getFirstSucceededRemoteDataPayload()
- );
- }
-
// TODO refactor this method to return RemoteData, and the template to deal with loading and errors
public getFiles(): Observable {
return this.bitstreamDataService
diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.ts
index a8b2514ffb..df27abd42e 100644
--- a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.ts
+++ b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.ts
@@ -48,8 +48,8 @@ export class PoolSearchResultDetailElementComponent extends SearchResultDetailEl
*/
ngOnInit() {
super.ngOnInit();
- this.linkService.resolveLinks(this.dso, followLink('workflowitem', null, true, true, true,
- followLink('item', null, true, true, true, followLink('bundles')),
+ this.linkService.resolveLinks(this.dso, followLink('workflowitem', {},
+ followLink('item', {}, followLink('bundles')),
followLink('submitter')
), followLink('action'));
this.workflowitemRD$ = this.dso.workflowitem as Observable>;
diff --git a/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.html b/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.html
index 9b9d174704..d47e897edc 100644
--- a/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.html
+++ b/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.html
@@ -1,11 +1,11 @@
-
-
+
+
-
-
+
+
{{object.name}}
diff --git a/src/app/shared/object-grid/community-grid-element/community-grid-element.component.html b/src/app/shared/object-grid/community-grid-element/community-grid-element.component.html
index f676ba303b..63097c4f57 100644
--- a/src/app/shared/object-grid/community-grid-element/community-grid-element.component.html
+++ b/src/app/shared/object-grid/community-grid-element/community-grid-element.component.html
@@ -1,11 +1,11 @@
-
-
+
+
-
-
+
+
{{object.name}}
diff --git a/src/app/shared/object-grid/grid-thumbnail/grid-thumbnail.component.html b/src/app/shared/object-grid/grid-thumbnail/grid-thumbnail.component.html
deleted file mode 100644
index 1df4026f83..0000000000
--- a/src/app/shared/object-grid/grid-thumbnail/grid-thumbnail.component.html
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/src/app/shared/object-grid/grid-thumbnail/grid-thumbnail.component.scss b/src/app/shared/object-grid/grid-thumbnail/grid-thumbnail.component.scss
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/src/app/shared/object-grid/grid-thumbnail/grid-thumbnail.component.spec.ts b/src/app/shared/object-grid/grid-thumbnail/grid-thumbnail.component.spec.ts
deleted file mode 100644
index 825a4d5c60..0000000000
--- a/src/app/shared/object-grid/grid-thumbnail/grid-thumbnail.component.spec.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { DebugElement } from '@angular/core';
-import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
-import { By } from '@angular/platform-browser';
-import { Bitstream } from '../../../core/shared/bitstream.model';
-import { SafeUrlPipe } from '../../utils/safe-url-pipe';
-
-import { GridThumbnailComponent } from './grid-thumbnail.component';
-
-describe('GridThumbnailComponent', () => {
- let comp: GridThumbnailComponent;
- let fixture: ComponentFixture
;
- let de: DebugElement;
- let el: HTMLElement;
-
- beforeEach(waitForAsync(() => {
- TestBed.configureTestingModule({
- declarations: [GridThumbnailComponent, SafeUrlPipe]
- }).compileComponents();
- }));
-
- beforeEach(() => {
- fixture = TestBed.createComponent(GridThumbnailComponent);
- comp = fixture.componentInstance; // BannerComponent test instance
- de = fixture.debugElement.query(By.css('div.thumbnail'));
- el = de.nativeElement;
- });
-
- it('should display image', () => {
- const thumbnail = new Bitstream();
- thumbnail._links = {
- self: { href: 'self.url' },
- bundle: { href: 'bundle.url' },
- format: { href: 'format.url' },
- content: { href: 'content.url' },
- };
- comp.thumbnail = thumbnail;
- fixture.detectChanges();
- const image: HTMLElement = de.query(By.css('img')).nativeElement;
- expect(image.getAttribute('src')).toBe(comp.thumbnail._links.content.href);
- });
-
- it('should display placeholder', () => {
- const thumbnail = new Bitstream();
- comp.thumbnail = thumbnail;
- fixture.detectChanges();
- const image: HTMLElement = de.query(By.css('img')).nativeElement;
- expect(image.getAttribute('src')).toBe(comp.defaultImage);
- });
-
-});
diff --git a/src/app/shared/object-grid/grid-thumbnail/grid-thumbnail.component.ts b/src/app/shared/object-grid/grid-thumbnail/grid-thumbnail.component.ts
deleted file mode 100644
index 92d93686dc..0000000000
--- a/src/app/shared/object-grid/grid-thumbnail/grid-thumbnail.component.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import {
- Component,
- Input,
- OnChanges,
- OnInit,
- SimpleChanges,
-} from '@angular/core';
-import { Bitstream } from '../../../core/shared/bitstream.model';
-import { hasValue } from '../../empty.util';
-
-/**
- * This component renders a given Bitstream as a thumbnail.
- * One input parameter of type Bitstream is expected.
- * If no Bitstream is provided, a holderjs image will be rendered instead.
- */
-
-@Component({
- selector: 'ds-grid-thumbnail',
- styleUrls: ['./grid-thumbnail.component.scss'],
- templateUrl: './grid-thumbnail.component.html',
-})
-export class GridThumbnailComponent implements OnInit, OnChanges {
- @Input() thumbnail: Bitstream;
-
- data: any = {};
-
- /**
- * The default 'holder.js' image
- */
- @Input() defaultImage? =
- 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9InllcyI/PjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMjYwIiBoZWlnaHQ9IjE4MCIgdmlld0JveD0iMCAwIDI2MCAxODAiIHByZXNlcnZlQXNwZWN0UmF0aW89Im5vbmUiPjwhLS0KU291cmNlIFVSTDogaG9sZGVyLmpzLzEwMCV4MTgwL3RleHQ6Tm8gVGh1bWJuYWlsCkNyZWF0ZWQgd2l0aCBIb2xkZXIuanMgMi42LjAuCkxlYXJuIG1vcmUgYXQgaHR0cDovL2hvbGRlcmpzLmNvbQooYykgMjAxMi0yMDE1IEl2YW4gTWFsb3BpbnNreSAtIGh0dHA6Ly9pbXNreS5jbwotLT48ZGVmcz48c3R5bGUgdHlwZT0idGV4dC9jc3MiPjwhW0NEQVRBWyNob2xkZXJfMTVmNzJmMmFlMGIgdGV4dCB7IGZpbGw6I0FBQUFBQTtmb250LXdlaWdodDpib2xkO2ZvbnQtZmFtaWx5OkFyaWFsLCBIZWx2ZXRpY2EsIE9wZW4gU2Fucywgc2Fucy1zZXJpZiwgbW9ub3NwYWNlO2ZvbnQtc2l6ZToxM3B0IH0gXV0+PC9zdHlsZT48L2RlZnM+PGcgaWQ9ImhvbGRlcl8xNWY3MmYyYWUwYiI+PHJlY3Qgd2lkdGg9IjI2MCIgaGVpZ2h0PSIxODAiIGZpbGw9IiNFRUVFRUUiLz48Zz48dGV4dCB4PSI3Mi4yNDIxODc1IiB5PSI5NiI+Tm8gVGh1bWJuYWlsPC90ZXh0PjwvZz48L2c+PC9zdmc+';
-
- src: string;
-
- errorHandler(event) {
- event.currentTarget.src = this.defaultImage;
- }
-
- /**
- * Initialize the src
- */
- ngOnInit(): void {
- this.src = this.defaultImage;
-
- this.checkThumbnail(this.thumbnail);
- }
-
- /**
- * If the old input is undefined and the new one is a bitsream then set src
- */
- ngOnChanges(changes: SimpleChanges): void {
- if (
- !hasValue(changes.thumbnail.previousValue) &&
- hasValue(changes.thumbnail.currentValue)
- ) {
- this.checkThumbnail(changes.thumbnail.currentValue);
- }
- }
-
- /**
- * check if the Bitstream has any content than set the src
- */
- checkThumbnail(thumbnail: Bitstream) {
- if (
- hasValue(thumbnail) &&
- hasValue(thumbnail._links) &&
- thumbnail._links.content.href
- ) {
- this.src = thumbnail._links.content.href;
- }
- }
-}
diff --git a/src/app/shared/object-grid/object-grid.component.scss b/src/app/shared/object-grid/object-grid.component.scss
index 46675615f0..68a7f2f991 100644
--- a/src/app/shared/object-grid/object-grid.component.scss
+++ b/src/app/shared/object-grid/object-grid.component.scss
@@ -1,7 +1,7 @@
:host ::ng-deep {
--ds-wrapper-grid-spacing: calc(var(--bs-spacer) / 2);
- div.thumbnail > img {
+ div.thumbnail > .thumbnail-content {
height: var(--ds-card-thumbnail-height);
width: 100%;
display: block;
diff --git a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.html
index f8c75fc0d4..739fa6c7a8 100644
--- a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.html
+++ b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.html
@@ -1,11 +1,11 @@
-
-
+
+
-
-
+
+
diff --git a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.html
index 8025213b3b..d8c253c8a9 100644
--- a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.html
+++ b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.html
@@ -1,11 +1,11 @@
-
-
+
+
-
-
+
+
diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html
index 85aeb63a6b..d2454b28e6 100644
--- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html
+++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html
@@ -6,14 +6,14 @@
-
-
+
+
-
-
+
+
@@ -43,4 +43,3 @@
-
diff --git a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts
index 7436d2922e..da1f0ea11b 100644
--- a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts
+++ b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts
@@ -3,13 +3,11 @@ import { Observable } from 'rxjs';
import { SearchResult } from '../../search/search-result.model';
import { BitstreamDataService } from '../../../core/data/bitstream-data.service';
-import { Bitstream } from '../../../core/shared/bitstream.model';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { Metadata } from '../../../core/shared/metadata.utils';
import { hasValue } from '../../empty.util';
import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component';
import { TruncatableService } from '../../truncatable/truncatable.service';
-import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators';
@Component({
selector: 'ds-search-result-grid-element',
@@ -66,11 +64,4 @@ export class SearchResultGridElementComponent
, K exten
private isCollapsed(): Observable {
return this.truncatableService.isCollapsed(this.dso.id);
}
-
- // TODO refactor to return RemoteData, and thumbnail template to deal with loading
- getThumbnail(): Observable {
- return this.bitstreamDataService.getThumbnailFor(this.dso as any).pipe(
- getFirstSucceededRemoteDataPayload()
- );
- }
}
diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.ts
index 5571782ce2..eaf407d787 100644
--- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.ts
+++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.ts
@@ -55,10 +55,7 @@ export class ClaimedApprovedSearchResultListElementComponent extends SearchResul
super.ngOnInit();
this.linkService.resolveLinks(this.dso,
followLink('workflowitem',
- null,
- true,
- false,
- true,
+ { useCachedVersionIfAvailable: false },
followLink('item'),
followLink('submitter')
),
diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.ts
index 630aa699a7..0b9a925dbf 100644
--- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.ts
+++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.ts
@@ -56,10 +56,7 @@ export class ClaimedDeclinedSearchResultListElementComponent extends SearchResul
super.ngOnInit();
this.linkService.resolveLinks(this.dso,
followLink('workflowitem',
- null,
- true,
- false,
- true,
+ { useCachedVersionIfAvailable: false },
followLink('item'),
followLink('submitter')
),
diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts
index dae3272889..2cf8f9a231 100644
--- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts
+++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts
@@ -50,7 +50,7 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle
*/
ngOnInit() {
super.ngOnInit();
- this.linkService.resolveLinks(this.dso, followLink('workflowitem', null, true, true, true,
+ this.linkService.resolveLinks(this.dso, followLink('workflowitem', {},
followLink('item'), followLink('submitter')
), followLink('action'));
this.workflowitemRD$ = this.dso.workflowitem as Observable>;
diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts
index fe4fa715ee..e9d64db572 100644
--- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts
+++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts
@@ -60,7 +60,7 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen
*/
ngOnInit() {
super.ngOnInit();
- this.linkService.resolveLinks(this.dso, followLink('workflowitem', null, true, true, true,
+ this.linkService.resolveLinks(this.dso, followLink('workflowitem', {},
followLink('item'), followLink('submitter')
), followLink('action'));
this.workflowitemRD$ = this.dso.workflowitem as Observable>;
diff --git a/src/app/shared/object-list/type-badge/type-badge.component.html b/src/app/shared/object-list/type-badge/type-badge.component.html
index 18aeeb4bca..0c2bd7544e 100644
--- a/src/app/shared/object-list/type-badge/type-badge.component.html
+++ b/src/app/shared/object-list/type-badge/type-badge.component.html
@@ -1,3 +1,3 @@
- {{ typeMessage | translate }}
+ {{ typeMessage | translate }}
diff --git a/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html
index 5e6bcfaf8b..44aed494e3 100644
--- a/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html
+++ b/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html
@@ -8,15 +8,18 @@
diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html
index cf4876e34f..e2e57e7370 100644
--- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html
+++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html
@@ -1,10 +1,13 @@
-
-
+
+
+
{{ 'search.filters.' + filterConfig.name + '.' + filterValue.value | translate: {default: filterValue.value} }}
-
+
+
{{filterValue.count}}
diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html
index 4bcfc02966..d6cb7a3d79 100644
--- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html
+++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html
@@ -1,8 +1,11 @@
+
{{ 'search.filters.' + filterConfig.name + '.' + selectedValue.value | translate: {default: selectedValue.label} }}
+
diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-filter.component.html
index eb2105f4e7..230f072772 100644
--- a/src/app/shared/search/search-filters/search-filter/search-filter.component.html
+++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.html
@@ -1,17 +1,21 @@
-
-
+
+
{{'search.filters.filter.' + filter.name + '.head'| translate}}
-
+ [title]="((collapsed$ | async) ? 'search.filters.filter.expand' : 'search.filters.filter.collapse') | translate">
-
+
+ class="search-filter-wrapper" [ngClass]="{ 'closed' : closed, 'notab': notab }">
diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.scss b/src/app/shared/search/search-filters/search-filter/search-filter.component.scss
index 518e7c9d5f..7e2631b55f 100644
--- a/src/app/shared/search/search-filters/search-filter/search-filter.component.scss
+++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.scss
@@ -1,10 +1,36 @@
:host .facet-filter {
- border: 1px solid var(--bs-light);
- cursor: pointer;
- .search-filter-wrapper.closed {
- overflow: hidden;
+ border: 1px solid var(--bs-light);
+ cursor: pointer;
+ line-height: 0;
+
+ .search-filter-wrapper {
+ line-height: var(--bs-line-height-base);
+ &.closed {
+ overflow: hidden;
}
- .filter-toggle {
- line-height: var(--bs-line-height-base);
+ &.notab {
+ visibility: hidden;
}
+ }
+
+ .filter-toggle {
+ line-height: var(--bs-line-height-base);
+ text-align: right;
+ position: relative;
+ top: -0.125rem; // Fix weird outline shape in Chrome
+ }
+
+ > button {
+ appearance: none;
+ border: 0;
+ padding: 0;
+ background: transparent;
+ width: 100%;
+ outline: none !important;
+ }
+
+ &.focus {
+ outline: none;
+ box-shadow: var(--bs-input-btn-focus-box-shadow);
+ }
}
diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts
index 228eef9a20..5e0077e11d 100644
--- a/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts
+++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.spec.ts
@@ -12,6 +12,7 @@ import { SearchFilterConfig } from '../../search-filter-config.model';
import { FilterType } from '../../filter-type.model';
import { SearchConfigurationServiceStub } from '../../../testing/search-configuration-service.stub';
import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
+import { SequenceService } from '../../../../core/shared/sequence.service';
describe('SearchFilterComponent', () => {
let comp: SearchFilterComponent;
@@ -50,12 +51,15 @@ describe('SearchFilterComponent', () => {
};
let filterService;
+ let sequenceService;
const mockResults = observableOf(['test', 'data']);
const searchServiceStub = {
getFacetValuesFor: (filter) => mockResults
};
beforeEach(waitForAsync(() => {
+ sequenceService = jasmine.createSpyObj('sequenceService', { next: 17 });
+
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule],
declarations: [SearchFilterComponent],
@@ -65,7 +69,8 @@ describe('SearchFilterComponent', () => {
provide: SearchFilterService,
useValue: mockFilterService
},
- { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }
+ { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() },
+ { provide: SequenceService, useValue: sequenceService },
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchFilterComponent, {
@@ -81,6 +86,12 @@ describe('SearchFilterComponent', () => {
filterService = (comp as any).filterService;
});
+ it('should generate unique IDs', () => {
+ expect(sequenceService.next).toHaveBeenCalled();
+ expect(comp.toggleId).toContain('17');
+ expect(comp.regionId).toContain('17');
+ });
+
describe('when the toggle method is triggered', () => {
beforeEach(() => {
spyOn(filterService, 'toggle');
diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-filter.component.ts
index 31ace10a7d..0f7f763b45 100644
--- a/src/app/shared/search/search-filters/search-filter/search-filter.component.ts
+++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.ts
@@ -10,6 +10,7 @@ import { isNotEmpty } from '../../../empty.util';
import { SearchService } from '../../../../core/shared/search/search.service';
import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service';
import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
+import { SequenceService } from '../../../../core/shared/sequence.service';
@Component({
selector: 'ds-search-filter',
@@ -37,6 +38,16 @@ export class SearchFilterComponent implements OnInit {
*/
closed: boolean;
+ /**
+ * True when the filter controls should be hidden & removed from the tablist
+ */
+ notab: boolean;
+
+ /**
+ * True when the filter toggle button is focused
+ */
+ focusBox = false;
+
/**
* Emits true when the filter is currently collapsed in the store
*/
@@ -52,10 +63,15 @@ export class SearchFilterComponent implements OnInit {
*/
active$: Observable;
+ private readonly sequenceId: number;
+
constructor(
private filterService: SearchFilterService,
private searchService: SearchService,
- @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService) {
+ @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService,
+ private sequenceService: SequenceService,
+ ) {
+ this.sequenceId = this.sequenceService.next();
}
/**
@@ -112,6 +128,9 @@ export class SearchFilterComponent implements OnInit {
if (event.fromState === 'collapsed') {
this.closed = false;
}
+ if (event.toState === 'collapsed') {
+ this.notab = true;
+ }
}
/**
@@ -122,6 +141,17 @@ export class SearchFilterComponent implements OnInit {
if (event.toState === 'collapsed') {
this.closed = true;
}
+ if (event.fromState === 'collapsed') {
+ this.notab = false;
+ }
+ }
+
+ get regionId(): string {
+ return `search-filter-region-${this.sequenceId}`;
+ }
+
+ get toggleId(): string {
+ return `search-filter-toggle-${this.sequenceId}`;
}
/**
diff --git a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html
index 06b60b5ecd..49ca6fe3fd 100644
--- a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html
+++ b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html
@@ -8,15 +8,18 @@