reinstated select multiple behavior

This commit is contained in:
lotte
2019-07-26 16:46:31 +02:00
parent 650b77081f
commit 16feb61ebf
15 changed files with 162 additions and 88 deletions

View File

@@ -5,7 +5,6 @@ import { GenericItemPageFieldComponent } from '../../field-components/specific-f
import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { SearchFixedFilterService } from '../../../../core/shared/search/search-fixed-filter.service';
import { TruncatableService } from '../../../../shared/truncatable/truncatable.service';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { Item } from '../../../../core/shared/item.model';
@@ -28,12 +27,6 @@ describe('PublicationComponent', () => {
let comp: PublicationComponent;
let fixture: ComponentFixture<PublicationComponent>;
const searchFixedFilterServiceStub = {
/* tslint:disable:no-empty */
getQueryByRelations: () => {}
/* tslint:enable:no-empty */
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
@@ -46,7 +39,6 @@ describe('PublicationComponent', () => {
providers: [
{provide: ITEM, useValue: mockItem},
{provide: ItemDataService, useValue: {}},
{provide: SearchFixedFilterService, useValue: searchFixedFilterServiceStub},
{provide: TruncatableService, useValue: {}}
],

View File

@@ -10,7 +10,6 @@ import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angula
import { ITEM } from '../../../../shared/items/switcher/item-type-switcher.component';
import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
import { isNotEmpty } from '../../../../shared/empty.util';
import { SearchFixedFilterService } from '../../../../core/shared/search/search-fixed-filter.service';
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { RemoteData } from '../../../../core/data/remote-data';
@@ -39,12 +38,6 @@ export function getItemPageFieldsTest(mockItem: Item, component) {
let comp: any;
let fixture: ComponentFixture<any>;
const searchFixedFilterServiceStub = {
/* tslint:disable:no-empty */
getQueryByRelations: () => {}
/* tslint:enable:no-empty */
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
@@ -57,7 +50,6 @@ export function getItemPageFieldsTest(mockItem: Item, component) {
providers: [
{provide: ITEM, useValue: mockItem},
{provide: ItemDataService, useValue: {}},
{provide: SearchFixedFilterService, useValue: searchFixedFilterServiceStub},
{provide: TruncatableService, useValue: {}}
],
@@ -366,13 +358,6 @@ describe('ItemComponent', () => {
authority: '123'
}
] as MetadataValue[];
const mockItemDataService = Object.assign({
findById: (id) => {
if (id === relatedItem.id) {
return observableOf(new RemoteData(false, false, true, null, relatedItem))
}
}
}) as ItemDataService;
let representations: Observable<MetadataRepresentation[]>;

View File

@@ -4,13 +4,11 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { SearchFixedFilterService } from '../../../../core/shared/search/search-fixed-filter.service';
import { Item } from '../../../../core/shared/item.model';
describe('RelatedEntitiesSearchComponent', () => {
let comp: RelatedEntitiesSearchComponent;
let fixture: ComponentFixture<RelatedEntitiesSearchComponent>;
let fixedFilterService: SearchFixedFilterService;
const mockItem = Object.assign(new Item(), {
id: 'id1'
@@ -18,17 +16,11 @@ describe('RelatedEntitiesSearchComponent', () => {
const mockRelationType = 'publicationsOfAuthor';
const mockRelationEntityType = 'publication';
const mockFilter= `f.${mockRelationType}=${mockItem.id}`;
const fixedFilterServiceStub = {
getFilterByRelation: () => mockFilter
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
declarations: [RelatedEntitiesSearchComponent],
providers: [
{ provide: SearchFixedFilterService, useValue: fixedFilterServiceStub }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
@@ -36,7 +28,6 @@ describe('RelatedEntitiesSearchComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(RelatedEntitiesSearchComponent);
comp = fixture.componentInstance;
fixedFilterService = (comp as any).fixedFilterService;
comp.relationType = mockRelationType;
comp.item = mockItem;
comp.relationEntityType = mockRelationEntityType;

View File

@@ -28,7 +28,6 @@ import { SearchFilterService } from '../core/shared/search/search-filter.service
import { RoleDirective } from '../shared/roles/role.directive';
import { RoleService } from '../core/roles/role.service';
import { MockRoleService } from '../shared/mocks/mock-role-service';
import { SearchFixedFilterService } from '../core/shared/search/search-fixed-filter.service';
describe('MyDSpacePageComponent', () => {
let comp: MyDSpacePageComponent;
@@ -80,11 +79,6 @@ describe('MyDSpacePageComponent', () => {
collapse: () => this.isCollapsed = observableOf(true),
expand: () => this.isCollapsed = observableOf(false)
};
const mockFixedFilterService: SearchFixedFilterService = {
getQueryByFilterName: (filter: string) => {
return observableOf(undefined)
}
} as SearchFixedFilterService;
beforeEach(async(() => {
TestBed.configureTestingModule({
@@ -124,10 +118,6 @@ describe('MyDSpacePageComponent', () => {
provide: RoleService,
useValue: new MockRoleService()
},
{
provide: SearchFixedFilterService,
useValue: mockFixedFilterService
}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(MyDSpacePageComponent, {

View File

@@ -24,7 +24,6 @@ import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.compone
import { RouteService } from '../shared/services/route.service';
import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub';
import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model';
import { SearchFixedFilterService } from '../core/shared/search/search-fixed-filter.service';
let comp: SearchPageComponent;
let fixture: ComponentFixture<SearchPageComponent>;
@@ -88,11 +87,6 @@ const routeServiceStub = {
return observableOf('')
}
};
const mockFixedFilterService: SearchFixedFilterService = {
getQueryByFilterName: (filter: string) => {
return observableOf(undefined)
}
} as SearchFixedFilterService;
export function configureSearchComponentTestingModule(compType) {
TestBed.configureTestingModule({
@@ -125,10 +119,6 @@ export function configureSearchComponentTestingModule(compType) {
provide: SearchFilterService,
useValue: {}
},
{
provide: SearchFixedFilterService,
useValue: mockFixedFilterService
},
{
provide: SearchConfigurationService,
useValue: {

View File

@@ -8,19 +8,23 @@ import { RemoteData } from '../data/remote-data';
import { ResourceType } from './resource-type';
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
import { hasNoValue } from '../../shared/empty.util';
import { excludeFromEquals } from '../utilities/equals.decorators';
/**
* An abstract model class for a DSpaceObject.
*/
export class DSpaceObject extends ListableObject implements CacheableObject {
@excludeFromEquals
private _name: string;
@excludeFromEquals
self: string;
/**
* The human-readable identifier of this DSpaceObject
*/
@excludeFromEquals
id: string;
/**
@@ -31,6 +35,7 @@ export class DSpaceObject extends ListableObject implements CacheableObject {
/**
* A string representing the kind of DSpaceObject, e.g. community, item, …
*/
@excludeFromEquals
type: ResourceType;
/**
@@ -50,6 +55,7 @@ export class DSpaceObject extends ListableObject implements CacheableObject {
/**
* All metadata of this DSpaceObject
*/
@excludeFromEquals
metadata: MetadataMap;
/**
@@ -62,11 +68,13 @@ export class DSpaceObject extends ListableObject implements CacheableObject {
/**
* An array of DSpaceObjects that are direct parents of this DSpaceObject
*/
@excludeFromEquals
parents: Observable<RemoteData<DSpaceObject[]>>;
/**
* The DSpaceObject that owns this DSpaceObject
*/
@excludeFromEquals
owner: Observable<RemoteData<DSpaceObject>>;
/**

View File

@@ -8,12 +8,25 @@ class Dog extends EquatableObject<Dog> {
@excludeFromEquals
public ballsCaught: number;
@fieldsForEquals('name', 'age')
public owner: {
name: string;
age: number;
public owner: Owner;
@fieldsForEquals('name')
public favouriteToy: { name: string, colour: string };
}
class Owner extends EquatableObject<Owner> {
@excludeFromEquals
favouriteFood: string;
constructor(
public name: string,
public age: number,
favouriteFood: string
) {
super();
this.favouriteFood = favouriteFood;
}
}
fdescribe('equatable', () => {
@@ -24,12 +37,14 @@ fdescribe('equatable', () => {
dogRoger = new Dog();
dogRoger.name = 'Roger';
dogRoger.ballsCaught = 6;
dogRoger.owner = { name: 'Tommy', age: 16, favouriteFood: 'spaghetti' };
dogRoger.owner = new Owner('Tommy', 16, 'spaghetti');
dogRoger.favouriteToy = { name: 'Twinky', colour: 'red' };
dogMissy = new Dog();
dogMissy.name = 'Missy';
dogMissy.ballsCaught = 9;
dogMissy.owner = { name: 'Jenny', age: 29, favouriteFood: 'pizza' };
dogMissy.owner = new Owner('Jenny', 29, 'pizza');
dogRoger.favouriteToy = { name: 'McSqueak', colour: 'grey' };
});
it('should return false when the other object is undefined', () => {
@@ -66,5 +81,33 @@ fdescribe('equatable', () => {
const isEqual = dogRoger.equals(copyOfDogRoger);
expect(isEqual).toBe(false);
});
it('should return true when the other object\'s nested object only differs in fields that are marked as excludeFromEquals, when the nested object is not marked decorated with @fieldsForEquals', () => {
const copyOfDogRoger = cloneDeep(dogRoger);
copyOfDogRoger.owner.favouriteFood = 'Sushi';
const isEqual = dogRoger.equals(copyOfDogRoger);
expect(isEqual).toBe(true);
});
it('should return false when the other object\'s nested object differs in fields that are not marked as excludeFromEquals, when the nested object is not marked decorated with @fieldsForEquals', () => {
const copyOfDogRoger = cloneDeep(dogRoger);
copyOfDogRoger.owner.age = 36;
const isEqual = dogRoger.equals(copyOfDogRoger);
expect(isEqual).toBe(false);
});
it('should return true when the other object\'s nested object does not differ in fields that are listed inside the nested @fieldsForEquals decorator', () => {
const copyOfDogRoger = cloneDeep(dogRoger);
copyOfDogRoger.favouriteToy.colour = 'green';
const isEqual = dogRoger.equals(copyOfDogRoger);
expect(isEqual).toBe(true);
});
it('should return false when the other object\'s nested object differs in fields that are listed inside the nested @fieldsForEquals decorator', () => {
const copyOfDogRoger = cloneDeep(dogRoger);
copyOfDogRoger.favouriteToy.name = 'Mister Bone';
const isEqual = dogRoger.equals(copyOfDogRoger);
expect(isEqual).toBe(false);
});
});

View File

@@ -33,7 +33,7 @@ export abstract class EquatableObject<T> {
return true;
}
const excludedKeys = getExcludedFromEqualsFor(this.constructor);
const keys = Object.keys(this).filter((key) => excludedKeys.findIndex((excludedKey) => key === excludedKey) < 0);
const keys = Object.keys(this).filter((key) => !excludedKeys.includes(key));
return equalsByFields(this, other, keys);
}
}

View File

@@ -19,6 +19,51 @@
<button class="btn btn-outline-secondary" type="submit">Go</button>
</div>
</form>
<div *ngIf="repeatable" class="position-absolute">
<div class="input-group mb-3">
<div class="input-group-prepend">
<div class="input-group-text">
<!-- In theory we don't need separate checkboxes for this,
but I wasn't able to get this to work correctly without them.
Checkboxes that are in the indeterminate state always switch to checked when clicked
This seemed like the cleanest and clearest solution to solve this issue for now.
-->
<input *ngIf="!allSelected && !(someSelected$ | async)"
type="checkbox"
[indeterminate]="false"
(change)="selectAll()">
<input *ngIf="!allSelected && (someSelected$ | async)"
type="checkbox"
[indeterminate]="true"
(change)="deselectAll()">
<input *ngIf="allSelected" type="checkbox"
[checked]="true"
(change)="deselectAll()">
</div>
</div>
<div ngbDropdown class="input-group-append">
<button *ngIf="selectAllLoading" type="button"
class="btn btn-outline-secondary rounded-right">
<span class="spinner-border spinner-border-sm" role="status"
aria-hidden="true"></span>
<span class="sr-only">Loading...</span>
</button>
<button *ngIf="!selectAllLoading" id="resultdropdown" type="button"
ngbDropdownToggle
class="btn btn-outline-secondary dropdown-toggle-split"
data-toggle="dropdown" aria-haspopup="true"
aria-expanded="false">
<span class="sr-only">Toggle Dropdown</span>
</button>
<div ngbDropdownMenu aria-labelledby="resultdropdown">
<button class="dropdown-item" (click)="selectPage(resultsRD?.payload?.page)">Select page</button>
<button class="dropdown-item" (click)="deselectPage(resultsRD?.payload?.page)">Deselect page</button>
<button class="dropdown-item" (click)="selectAll()">Select all</button>
<button class="dropdown-item" (click)="deselectAll()">Deselect all</button>
</div>
</div>
</div>
</div>
<ds-search-results [searchResults]="resultsRD"
[sortConfig]="this.searchConfig?.sort"
[searchConfig]="this.searchConfig"
@@ -29,10 +74,9 @@
</div>
</div>
<div class="modal-footer">
<small>Selected {{(selection | async)?.length || 0}} items</small>
<small>Selected {{(selection$ | async)?.length || 0}} items</small>
<div>
<button type="button" class="btn btn-outline-secondary" (click)="modal.dismiss()">Cancel
</button>
<button type="button" class="btn btn-outline-secondary" (click)="modal.dismiss()">Cancel</button>
<button type="button" class="btn btn-danger" (click)="close()">Ok</button>
</div>
</div>

View File

@@ -5,3 +5,7 @@
.modal-footer {
justify-content: space-between;
}
.position-absolute {
right: $spacer;
}

View File

@@ -8,15 +8,16 @@ import { PaginatedSearchOptions } from '../../../../../search/paginated-search-o
import { DSpaceObject } from '../../../../../../core/shared/dspace-object.model';
import { PaginationComponentOptions } from '../../../../../pagination/pagination-component-options.model';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { hasValue } from '../../../../../empty.util';
import { concat, filter, map, multicast, switchMap, take, takeWhile } from 'rxjs/operators';
import { NavigationEnd, Router } from '@angular/router';
import { hasValue, isNotEmpty } from '../../../../../empty.util';
import { concat, map, multicast, switchMap, take, takeWhile, tap } from 'rxjs/operators';
import { Router } from '@angular/router';
import { SEARCH_CONFIG_SERVICE } from '../../../../../../+my-dspace-page/my-dspace-page.component';
import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service';
import { SelectableListService } from '../../../../../object-list/selectable-list/selectable-list.service';
import { SelectableListState } from '../../../../../object-list/selectable-list/selectable-list.reducer';
import { ListableObject } from '../../../../../object-collection/shared/listable-object.model';
import { RouteService } from '../../../../../services/route.service';
import { getSucceededRemoteData } from '../../../../../../core/shared/operators';
const RELATION_TYPE_FILTER_PREFIX = 'f.entityType=';
@@ -40,12 +41,16 @@ export class DsDynamicLookupRelationModalComponent implements OnInit {
searchConfig: PaginatedSearchOptions;
repeatable: boolean;
searchQuery;
allSelected: boolean;
someSelected$: Observable<boolean>;
selectAllLoading: boolean;
initialPagination = Object.assign(new PaginationComponentOptions(), {
id: 'submission-relation-list',
pageSize: 10
});
selection: Observable<ListableObject[]>;
selection$: Observable<ListableObject[]>;
fixedFilter: string;
constructor(public modal: NgbActiveModal, private searchService: SearchService, private router: Router, private selectableListService: SelectableListService, private searchConfigService: SearchConfigurationService, private routeService: RouteService) {
}
@@ -54,7 +59,8 @@ export class DsDynamicLookupRelationModalComponent implements OnInit {
this.fixedFilter = RELATION_TYPE_FILTER_PREFIX + this.fieldName;
this.routeService.setParameter('fixedFilterQuery', this.fixedFilter);
this.selection = this.selectableListService.getSelectableList(this.listId).pipe(map((listState: SelectableListState) => hasValue(listState) && hasValue(listState.selection) ? listState.selection : []));
this.selection$ = this.selectableListService.getSelectableList(this.listId).pipe(map((listState: SelectableListState) => hasValue(listState) && hasValue(listState.selection) ? listState.selection : []));
this.someSelected$ = this.selection$.pipe(map((selection) => isNotEmpty(selection)));
this.resultsRD$ = this.searchConfigService.paginatedSearchOptions.pipe(
map((options) => {
return Object.assign(new PaginatedSearchOptions({}), options, { fixedFilter: RELATION_TYPE_FILTER_PREFIX + this.fieldName })
@@ -93,4 +99,36 @@ export class DsDynamicLookupRelationModalComponent implements OnInit {
queryParams: Object.assign({}, { page: 1, query: this.searchQuery }),
});
}
selectPage(page: SearchResult<DSpaceObject>[]) {
this.selectableListService.select(this.listId, page);
}
deselectPage(page: SearchResult<DSpaceObject>[]) {
this.allSelected = false;
this.selectableListService.deselect(this.listId, page);
}
selectAll() {
this.allSelected = true;
this.selectAllLoading = true;
const fullPagination = Object.assign(new PaginationComponentOptions(), {
query: this.searchQuery,
currentPage: 1,
pageSize: Number.POSITIVE_INFINITY
});
const fullSearchConfig = Object.assign(this.searchConfig, { pagination: fullPagination });
const results = this.searchService.search(fullSearchConfig);
results.pipe(
getSucceededRemoteData(),
map((resultsRD) => resultsRD.payload.page),
tap(() => this.selectAllLoading = false),
).subscribe((results) => this.selectableListService.select(this.listId, results));
}
deselectAll() {
this.allSelected = false;
this.selectableListService.deselectAll(this.listId);
}
}

View File

@@ -50,7 +50,6 @@ export class SearchFiltersComponent implements OnInit {
private filterService: SearchFilterService,
private router: Router,
@Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService) {
}
ngOnInit(): void {

View File

@@ -1,6 +1,7 @@
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { MetadataMap } from '../../core/shared/metadata.models';
import { ListableObject } from '../object-collection/shared/listable-object.model';
import { excludeFromEquals, fieldsForEquals } from '../../core/utilities/equals.decorators';
/**
* Represents a search result object of a certain (<T>) DSpaceObject
@@ -9,14 +10,12 @@ export class SearchResult<T extends DSpaceObject> extends ListableObject {
/**
* The DSpaceObject that was found
*/
@fieldsForEquals('uuid')
indexableObject: T;
/**
* The metadata that was used to find this item, hithighlighted
*/
@excludeFromEquals
hitHighlights: MetadataMap;
get id(): string {
return this.indexableObject.id;
}
}

View File

@@ -27,15 +27,14 @@ import { SubmissionJsonPatchOperationsService } from '../../../core/submission/s
import { SubmissionJsonPatchOperationsServiceStub } from '../../../shared/testing/submission-json-patch-operations-service-stub';
import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder';
import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner';
import { Community } from '../../../core/shared/community.model';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { PageInfo } from '../../../core/shared/page-info.model';
import { Collection } from '../../../core/shared/collection.model';
import { createTestComponent } from '../../../shared/testing/utils';
import { cold } from 'jasmine-marbles';
import { SearchResult } from '../../../+search-page/search-result.model';
import { SearchService } from '../../../+search-page/search-service/search.service';
import { SearchResult } from '../../../shared/search/search-result.model';
import { SearchService } from '../../../core/shared/search/search.service';
const mockCommunity1Collection1 = Object.assign(new Collection(), {
name: 'Community 1-Collection 1',

View File

@@ -12,15 +12,7 @@ import {
import { FormControl } from '@angular/forms';
import { BehaviorSubject, combineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
filter,
find,
map,
mergeMap,
startWith
} from 'rxjs/operators';
import { debounceTime, distinctUntilChanged, filter, map, startWith } from 'rxjs/operators';
import { Collection } from '../../../core/shared/collection.model';
import { CommunityDataService } from '../../../core/data/community-data.service';
@@ -32,12 +24,12 @@ import { PaginatedList } from '../../../core/data/paginated-list';
import { SubmissionService } from '../../submission.service';
import { SubmissionObject } from '../../../core/submission/models/submission-object.model';
import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service';
import { SearchService } from '../../../+search-page/search-service/search.service';
import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
import { getSucceededRemoteData } from '../../../core/shared/operators';
import { SearchResult } from '../../../+search-page/search-result.model';
import { SearchService } from '../../../core/shared/search/search.service';
import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model';
import { SearchResult } from '../../../shared/search/search-result.model';
/**
* An interface to represent a collection entry