diff --git a/e2e/app.po.ts b/e2e/app.po.ts index 54b5b55af3..c76bef118f 100644 --- a/e2e/app.po.ts +++ b/e2e/app.po.ts @@ -2,7 +2,8 @@ import { browser, element, by } from 'protractor'; export class ProtractorPage { navigateTo() { - return browser.get('/'); + return browser.get('/') + .then(() => browser.waitForAngular()); } getPageTitleText() { diff --git a/karma.conf.js b/karma.conf.js index 456c2ecd99..f40c8b2166 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -15,7 +15,11 @@ module.exports = function (config) { }; var configuration = { - + client: { + jasmine: { + random: false + } + }, // base path that will be used to resolve all patterns (e.g. files, exclude) basePath: '', diff --git a/package.json b/package.json index 3a54b941dd..aaabc0271a 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ }, "dependencies": { "@angular/animations": "^6.1.4", + "@angular/cdk": "^6.4.7", "@angular/cli": "^6.1.5", "@angular/common": "^6.1.4", "@angular/core": "^6.1.4", @@ -139,6 +140,7 @@ "text-mask-core": "5.0.1", "ts-loader": "^5.2.1", "ts-md5": "^1.2.4", + "url-parse": "^1.4.7", "uuid": "^3.2.1", "webfontloader": "1.6.28", "webpack-cli": "^3.1.0", @@ -228,7 +230,7 @@ "rollup-plugin-node-globals": "1.2.1", "rollup-plugin-node-resolve": "^3.0.3", "rollup-plugin-terser": "^2.0.2", - "sass-loader": "7.1.0", + "sass-loader": "^7.1.0", "script-ext-html-webpack-plugin": "2.0.1", "source-map": "0.7.3", "source-map-loader": "0.2.4", diff --git a/resources/fonts/README.md b/resources/fonts/README.md new file mode 100644 index 0000000000..e4817b8572 --- /dev/null +++ b/resources/fonts/README.md @@ -0,0 +1,3 @@ +# Supported font formats + +DSpace supports EOT, TTF, OTF, SVG, WOFF and WOFF2 fonts. diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index 0c34aa88b2..16b4fa1dd9 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -244,6 +244,8 @@ "collection.create.head": "Create a Collection", + "collection.create.notifications.success": "Successfully created the Collection", + "collection.create.sub-head": "Create a Collection for Community {{ parent }}", "collection.delete.cancel": "Cancel", @@ -302,6 +304,46 @@ + "collection.edit.logo.label": "Collection logo", + + "collection.edit.logo.notifications.add.error": "Uploading Collection logo failed. Please verify the content before retrying.", + + "collection.edit.logo.notifications.add.success": "Upload Collection logo successful.", + + "collection.edit.logo.notifications.delete.success.title": "Logo deleted", + + "collection.edit.logo.notifications.delete.success.content": "Successfully deleted the collection's logo", + + "collection.edit.logo.notifications.delete.error.title": "Error deleting logo", + + "collection.edit.logo.upload": "Drop a Collection Logo to upload", + + + + "collection.edit.notifications.success": "Successfully edited the Collection", + + "collection.edit.return": "Return", + + + + "collection.edit.tabs.curate.head": "Curate", + + "collection.edit.tabs.curate.title": "Collection Edit - Curate", + + "collection.edit.tabs.metadata.head": "Edit Metadata", + + "collection.edit.tabs.metadata.title": "Collection Edit - Metadata", + + "collection.edit.tabs.roles.head": "Assign Roles", + + "collection.edit.tabs.roles.title": "Collection Edit - Roles", + + "collection.edit.tabs.source.head": "Content Source", + + "collection.edit.tabs.source.title": "Collection Edit - Content Source", + + + "collection.form.abstract": "Short Description", "collection.form.description": "Introductory text (HTML)", @@ -340,8 +382,18 @@ + "communityList.tabTitle": "DSpace - Community List", + + "communityList.title": "List of Communities", + + "communityList.showMore": "Show More", + + + "community.create.head": "Create a Community", + "community.create.notifications.success": "Successfully created the Community", + "community.create.sub-head": "Create a Sub-Community for Community {{ parent }}", "community.delete.cancel": "Cancel", @@ -360,6 +412,44 @@ "community.edit.head": "Edit Community", + + + "community.edit.logo.label": "Community logo", + + "community.edit.logo.notifications.add.error": "Uploading Community logo failed. Please verify the content before retrying.", + + "community.edit.logo.notifications.add.success": "Upload Community logo successful.", + + "community.edit.logo.notifications.delete.success.title": "Logo deleted", + + "community.edit.logo.notifications.delete.success.content": "Successfully deleted the community's logo", + + "community.edit.logo.notifications.delete.error.title": "Error deleting logo", + + "community.edit.logo.upload": "Drop a Community Logo to upload", + + + + "community.edit.notifications.success": "Successfully edited the Community", + + "community.edit.return": "Return", + + + + "community.edit.tabs.curate.head": "Curate", + + "community.edit.tabs.curate.title": "Community Edit - Curate", + + "community.edit.tabs.metadata.head": "Edit Metadata", + + "community.edit.tabs.metadata.title": "Community Edit - Metadata", + + "community.edit.tabs.roles.head": "Assign Roles", + + "community.edit.tabs.roles.title": "Community Edit - Roles", + + + "community.form.abstract": "Short Description", "community.form.description": "Introductory text (HTML)", @@ -451,6 +541,9 @@ "footer.link.duraspace": "DuraSpace", + "form.add": "Add", + + "form.add-help": "Click here to add the current entry and to add another one", "form.cancel": "Cancel", @@ -476,6 +569,10 @@ "form.loading": "Loading...", + "form.lookup": "Lookup", + + "form.lookup-help": "Click here to look up an existing relation", + "form.no-results": "No results found", "form.no-value": "No value entered", @@ -716,7 +813,7 @@ "item.edit.tabs.relationships.head": "Item Relationships", - "item.edit.tabs.relationships.title": "Item Edit - Relationships", + "item.edit.tabs.relationships.title": "Item Edit - Relationships", "item.edit.tabs.status.buttons.authorizations.button": "Authorizations...", @@ -814,9 +911,17 @@ "item.page.person.search.title": "Articles by this author", - "item.page.related-items.view-more": "View more", + "item.page.related-items.view-more": "Show {{ amount }} more", - "item.page.related-items.view-less": "View less", + "item.page.related-items.view-less": "Hide last {{ amount }}", + + "item.page.relationships.isAuthorOfPublication": "Publications", + + "item.page.relationships.isJournalOfPublication": "Publications", + + "item.page.relationships.isOrgUnitOfPerson": "Authors", + + "item.page.relationships.isOrgUnitOfProject": "Research Projects", "item.page.subject": "Keywords", @@ -1268,6 +1373,8 @@ "project.page.titleprefix": "Research Project: ", + "project.search.results.head": "Project Search Results", + "publication.listelement.badge": "Publication", @@ -1343,6 +1450,9 @@ "search.filters.applied.f.subject": "Subject", "search.filters.applied.f.submitter": "Submitter", + "search.filters.applied.f.jobTitle": "Job Title", + "search.filters.applied.f.birthDate.max": "End birth date", + "search.filters.applied.f.birthDate.min": "Start birth date", @@ -1511,6 +1621,69 @@ "submission.general.save-later": "Save for later", + "submission.sections.describe.relationship-lookup.close": "Close", + + "submission.sections.describe.relationship-lookup.search-tab.deselect-all": "Deselect all", + + "submission.sections.describe.relationship-lookup.search-tab.deselect-page": "Deselect page", + + "submission.sections.describe.relationship-lookup.search-tab.loading": "Loading...", + + "submission.sections.describe.relationship-lookup.search-tab.placeholder": "Search query", + + "submission.sections.describe.relationship-lookup.search-tab.search": "Go", + + "submission.sections.describe.relationship-lookup.search-tab.select-all": "Select all", + + "submission.sections.describe.relationship-lookup.search-tab.select-page": "Select page", + + "submission.sections.describe.relationship-lookup.selected": "Selected {{ size }} items", + + "submission.sections.describe.relationship-lookup.search-tab.tab-title.Author": "Search for Authors", + + "submission.sections.describe.relationship-lookup.search-tab.tab-title.Journal": "Search for Journals", + + "submission.sections.describe.relationship-lookup.search-tab.tab-title.Journal Issue": "Search for Journal Issues", + + "submission.sections.describe.relationship-lookup.search-tab.tab-title.Journal Volume": "Search for Journal Volumes", + + "submission.sections.describe.relationship-lookup.search-tab.tab-title.Funding Agency": "Search for Funding Agencies", + + "submission.sections.describe.relationship-lookup.search-tab.tab-title.Funding": "Search for Funding", + + "submission.sections.describe.relationship-lookup.selection-tab.tab-title": "Current Selection ({{ count }})", + + "submission.sections.describe.relationship-lookup.title.Journal Issue": "Journal Issues", + + "submission.sections.describe.relationship-lookup.title.Journal Volume": "Journal Volumes", + + "submission.sections.describe.relationship-lookup.title.Journal": "Journals", + + "submission.sections.describe.relationship-lookup.title.Author": "Authors", + + "submission.sections.describe.relationship-lookup.title.Funding Agency": "Funding Agency", + + "submission.sections.describe.relationship-lookup.title.Funding": "Funding", + + "submission.sections.describe.relationship-lookup.search-tab.toggle-dropdown": "Toggle dropdown", + + "submission.sections.describe.relationship-lookup.selection-tab.settings": "Settings", + + "submission.sections.describe.relationship-lookup.selection-tab.no-selection": "Your selection is currently empty.", + + "submission.sections.describe.relationship-lookup.selection-tab.title.Author": "Selected Authors", + + "submission.sections.describe.relationship-lookup.selection-tab.title.Journal": "Selected Journals", + + "submission.sections.describe.relationship-lookup.selection-tab.title.Journal Volume": "Selected Journal Volume", + + "submission.sections.describe.relationship-lookup.selection-tab.title.Journal Issue": "Selected Issue", + + "submission.sections.describe.relationship-lookup.name-variant.notification.content": "Would you like to save \"{{ value }}\" as a name variant for this person so you and others can reuse it for future submissions? If you don\'t you can still use it for this submission.", + + "submission.sections.describe.relationship-lookup.name-variant.notification.confirm": "Save a new name variant", + + "submission.sections.describe.relationship-lookup.name-variant.notification.decline": "Use only for this submission", "submission.sections.general.add-more": "Add more", @@ -1680,7 +1853,7 @@ "uploader.drag-message": "Drag & Drop your files here", - "uploader.or": ", or", + "uploader.or": ", or ", "uploader.processing": "Processing", diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts index cb7aa1ef91..ec4003c108 100644 --- a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts @@ -5,7 +5,7 @@ import { PaginatedList } from '../../../core/data/paginated-list'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { BitstreamFormat } from '../../../core/shared/bitstream-format.model'; import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service'; -import { FindAllOptions } from '../../../core/data/request.models'; +import { FindListOptions } from '../../../core/data/request.models'; import { map, switchMap, take } from 'rxjs/operators'; import { hasValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; @@ -35,7 +35,7 @@ export class BitstreamFormatsComponent implements OnInit { * The current pagination configuration for the page used by the FindAll method * Currently simply renders all bitstream formats */ - config: FindAllOptions = Object.assign(new FindAllOptions(), { + config: FindListOptions = Object.assign(new FindListOptions(), { elementsPerPage: 20 }); @@ -145,7 +145,7 @@ export class BitstreamFormatsComponent implements OnInit { * @param event The page change event */ onPageChange(event) { - this.config = Object.assign(new FindAllOptions(), this.config, { + this.config = Object.assign(new FindListOptions(), this.config, { currentPage: event, }); this.pageConfig.currentPage = event; diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts index 3ad1bd4272..185d083764 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts @@ -13,7 +13,6 @@ import { combineLatest as combineLatestObservable } from 'rxjs'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model'; import { CreateCommunityParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; -import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; import { CreateCollectionParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; import { EditItemSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; import { EditCommunitySelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; diff --git a/src/app/+collection-page/collection-form/collection-form.component.ts b/src/app/+collection-page/collection-form/collection-form.component.ts index 21b494f41f..76ba549f14 100644 --- a/src/app/+collection-page/collection-form/collection-form.component.ts +++ b/src/app/+collection-page/collection-form/collection-form.component.ts @@ -1,9 +1,15 @@ import { Component, Input } from '@angular/core'; -import { DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core'; +import { DynamicFormService, DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core'; import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynamic-form-control.model'; import { Collection } from '../../core/shared/collection.model'; import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component'; -import { NormalizedCollection } from '../../core/cache/models/normalized-collection.model'; +import { Location } from '@angular/common'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { AuthService } from '../../core/auth/auth.service'; +import { RequestService } from '../../core/data/request.service'; +import { ObjectCacheService } from '../../core/cache/object-cache.service'; /** * Form used for creating and editing collections @@ -22,7 +28,7 @@ export class CollectionFormComponent extends ComColFormComponent { /** * @type {Collection.type} This is a collection-type form */ - protected type = Collection.type; + type = Collection.type; /** * The dynamic form fields used for creating/editing a collection @@ -65,4 +71,15 @@ export class CollectionFormComponent extends ComColFormComponent { name: 'dc.description.provenance', }), ]; + + public constructor(protected location: Location, + protected formService: DynamicFormService, + protected translate: TranslateService, + protected notificationsService: NotificationsService, + protected authService: AuthService, + protected dsoService: CommunityDataService, + protected requestService: RequestService, + protected objectCache: ObjectCacheService) { + super(location, formService, translate, notificationsService, authService, requestService, objectCache); + } } diff --git a/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts index 0bbfb30821..62a8d8dabb 100644 --- a/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts +++ b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts @@ -1,29 +1,23 @@ import { CollectionItemMapperComponent } from './collection-item-mapper.component'; -import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { CommonModule } from '@angular/common'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { SearchFormComponent } from '../../shared/search-form/search-form.component'; -import { SearchPageModule } from '../../+search-page/search-page.module'; -import { ObjectCollectionComponent } from '../../shared/object-collection/object-collection.component'; import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; import { RouterStub } from '../../shared/testing/router-stub'; -import { SearchConfigurationService } from '../../+search-page/search-service/search-configuration.service'; -import { SearchService } from '../../+search-page/search-service/search.service'; import { SearchServiceStub } from '../../shared/testing/search-service-stub'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; import { ItemDataService } from '../../core/data/item-data.service'; import { FormsModule } from '@angular/forms'; -import { SharedModule } from '../../shared/shared.module'; import { Collection } from '../../core/shared/collection.model'; import { RemoteData } from '../../core/data/remote-data'; -import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { EventEmitter, NgModule } from '@angular/core'; +import { EventEmitter } from '@angular/core'; import { HostWindowService } from '../../shared/host-window.service'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service-stub'; import { By } from '@angular/platform-browser'; @@ -36,13 +30,14 @@ import { ItemSelectComponent } from '../../shared/object-select/item-select/item import { ObjectSelectService } from '../../shared/object-select/object-select.service'; import { ObjectSelectServiceStub } from '../../shared/testing/object-select-service-stub'; import { VarDirective } from '../../shared/utils/var.directive'; -import { Observable } from 'rxjs/internal/Observable'; import { of as observableOf, of } from 'rxjs/internal/observable/of'; import { RestResponse } from '../../core/cache/response.models'; -import { SearchFixedFilterService } from '../../+search-page/search-filters/search-filter/search-fixed-filter.service'; import { RouteService } from '../../core/services/route.service'; import { ErrorComponent } from '../../shared/error/error.component'; import { LoadingComponent } from '../../shared/loading/loading.component'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { SearchService } from '../../core/shared/search/search.service'; +import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; describe('CollectionItemMapperComponent', () => { let comp: CollectionItemMapperComponent; @@ -135,7 +130,6 @@ describe('CollectionItemMapperComponent', () => { { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: ObjectSelectService, useValue: new ObjectSelectServiceStub() }, { provide: RouteService, useValue: routeServiceStub }, - { provide: SearchFixedFilterService, useValue: fixedFilterServiceStub } ] }).compileComponents(); })); diff --git a/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts index 750578cc35..5c67a78401 100644 --- a/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts +++ b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts @@ -5,12 +5,9 @@ import { fadeIn, fadeInOut } from '../../shared/animations/fade'; import { ActivatedRoute, Router } from '@angular/router'; import { RemoteData } from '../../core/data/remote-data'; import { Collection } from '../../core/shared/collection.model'; -import { SearchConfigurationService } from '../../+search-page/search-service/search-configuration.service'; -import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model'; import { PaginatedList } from '../../core/data/paginated-list'; -import { map, startWith, switchMap, take, tap } from 'rxjs/operators'; +import { map, startWith, switchMap, take } from 'rxjs/operators'; import { getRemoteDataPayload, getSucceededRemoteData, toDSpaceObjectListRD } from '../../core/shared/operators'; -import { SearchService } from '../../+search-page/search-service/search.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; @@ -22,6 +19,9 @@ import { isNotEmpty } from '../../shared/empty.util'; import { RestResponse } from '../../core/cache/response.models'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { SearchService } from '../../core/shared/search/search.service'; @Component({ selector: 'ds-collection-item-mapper', diff --git a/src/app/+collection-page/collection-page-routing.module.ts b/src/app/+collection-page/collection-page-routing.module.ts index 66c623657d..2df7997e1e 100644 --- a/src/app/+collection-page/collection-page-routing.module.ts +++ b/src/app/+collection-page/collection-page-routing.module.ts @@ -5,7 +5,6 @@ import { CollectionPageComponent } from './collection-page.component'; import { CollectionPageResolver } from './collection-page.resolver'; import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; -import { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component'; import { CreateCollectionPageGuard } from './create-collection-page/create-collection-page.guard'; import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; import { URLCombiner } from '../core/url-combiner/url-combiner'; @@ -39,12 +38,8 @@ const COLLECTION_EDIT_PATH = ':id/edit'; }, { path: COLLECTION_EDIT_PATH, - pathMatch: 'full', - component: EditCollectionPageComponent, - canActivate: [AuthenticatedGuard], - resolve: { - dso: CollectionPageResolver - } + loadChildren: './edit-collection-page/edit-collection-page.module#EditCollectionPageModule', + canActivate: [AuthenticatedGuard] }, { path: ':id/delete', diff --git a/src/app/+collection-page/collection-page.component.html b/src/app/+collection-page/collection-page.component.html index 436cd351a0..98552ed40b 100644 --- a/src/app/+collection-page/collection-page.component.html +++ b/src/app/+collection-page/collection-page.component.html @@ -3,16 +3,19 @@ *ngVar="(collectionRD$ | async) as collectionRD">
+
- - - [alternateText]="'Collection Logo'"> - - + + + + + {{'collection.create.sub-head' | translate:{ parent: (parentRD$| async)?.payload.name } }}
- + diff --git a/src/app/+collection-page/create-collection-page/create-collection-page.component.spec.ts b/src/app/+collection-page/create-collection-page/create-collection-page.component.spec.ts index e223b11c65..869a89d5e0 100644 --- a/src/app/+collection-page/create-collection-page/create-collection-page.component.spec.ts +++ b/src/app/+collection-page/create-collection-page/create-collection-page.component.spec.ts @@ -10,6 +10,8 @@ import { CollectionDataService } from '../../core/data/collection-data.service'; import { of as observableOf } from 'rxjs'; import { CommunityDataService } from '../../core/data/community-data.service'; import { CreateCollectionPageComponent } from './create-collection-page.component'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; describe('CreateCollectionPageComponent', () => { let comp: CreateCollectionPageComponent; @@ -27,6 +29,7 @@ describe('CreateCollectionPageComponent', () => { }, { provide: RouteService, useValue: { getQueryParameterValue: () => observableOf('1234') } }, { provide: Router, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/+collection-page/create-collection-page/create-collection-page.component.ts b/src/app/+collection-page/create-collection-page/create-collection-page.component.ts index 2cab36d285..ae31b94c3d 100644 --- a/src/app/+collection-page/create-collection-page/create-collection-page.component.ts +++ b/src/app/+collection-page/create-collection-page/create-collection-page.component.ts @@ -5,6 +5,8 @@ import { Router } from '@angular/router'; import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component'; import { Collection } from '../../core/shared/collection.model'; import { CollectionDataService } from '../../core/data/collection-data.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; /** * Component that represents the page where a user can create a new Collection @@ -16,13 +18,16 @@ import { CollectionDataService } from '../../core/data/collection-data.service'; }) export class CreateCollectionPageComponent extends CreateComColPageComponent { protected frontendURL = '/collections/'; + protected type = Collection.type; public constructor( protected communityDataService: CommunityDataService, protected collectionDataService: CollectionDataService, protected routeService: RouteService, - protected router: Router + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService ) { - super(collectionDataService, communityDataService, routeService, router); + super(collectionDataService, communityDataService, routeService, router, notificationsService, translate); } } diff --git a/src/app/+search-page/search-filters/search-filters.component.scss b/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.html similarity index 100% rename from src/app/+search-page/search-filters/search-filters.component.scss rename to src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.html diff --git a/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.ts b/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.ts new file mode 100644 index 0000000000..d7deaea982 --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/collection-curate/collection-curate.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +/** + * Component for managing a collection's curation tasks + */ +@Component({ + selector: 'ds-collection-curate', + templateUrl: './collection-curate.component.html', +}) +export class CollectionCurateComponent { + /* TODO: Implement Collection Edit - Curate */ +} diff --git a/src/app/+collection-page/edit-collection-page/collection-metadata/collection-metadata.component.html b/src/app/+collection-page/edit-collection-page/collection-metadata/collection-metadata.component.html new file mode 100644 index 0000000000..6f3a63790d --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/collection-metadata/collection-metadata.component.html @@ -0,0 +1,6 @@ + +{{'collection.edit.delete' + | translate}} diff --git a/src/app/+collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts b/src/app/+collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts new file mode 100644 index 0000000000..71cb06394f --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts @@ -0,0 +1,42 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { SharedModule } from '../../../shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { ActivatedRoute } from '@angular/router'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CollectionMetadataComponent } from './collection-metadata.component'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; + +describe('CollectionMetadataComponent', () => { + let comp: CollectionMetadataComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], + declarations: [CollectionMetadataComponent], + providers: [ + { provide: CollectionDataService, useValue: {} }, + { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: { payload: {} } }) } } }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CollectionMetadataComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('frontendURL', () => { + it('should have the right frontendURL set', () => { + expect((comp as any).frontendURL).toEqual('/collections/'); + }) + }); +}); diff --git a/src/app/+collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts b/src/app/+collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts new file mode 100644 index 0000000000..af2ab7d0a7 --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts @@ -0,0 +1,29 @@ +import { Component } from '@angular/core'; +import { ComcolMetadataComponent } from '../../../shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; +import { Collection } from '../../../core/shared/collection.model'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * Component for editing a collection's metadata + */ +@Component({ + selector: 'ds-collection-metadata', + templateUrl: './collection-metadata.component.html', +}) +export class CollectionMetadataComponent extends ComcolMetadataComponent { + protected frontendURL = '/collections/'; + protected type = Collection.type; + + public constructor( + protected collectionDataService: CollectionDataService, + protected router: Router, + protected route: ActivatedRoute, + protected notificationsService: NotificationsService, + protected translate: TranslateService + ) { + super(collectionDataService, router, route, notificationsService, translate); + } +} diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.scss b/src/app/+collection-page/edit-collection-page/collection-roles/collection-roles.component.html similarity index 100% rename from src/app/+search-page/search-switch-configuration/search-switch-configuration.component.scss rename to src/app/+collection-page/edit-collection-page/collection-roles/collection-roles.component.html diff --git a/src/app/+collection-page/edit-collection-page/collection-roles/collection-roles.component.ts b/src/app/+collection-page/edit-collection-page/collection-roles/collection-roles.component.ts new file mode 100644 index 0000000000..39f72fd2ce --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/collection-roles/collection-roles.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +/** + * Component for managing a collection's roles + */ +@Component({ + selector: 'ds-collection-roles', + templateUrl: './collection-roles.component.html', +}) +export class CollectionRolesComponent { + /* TODO: Implement Collection Edit - Roles */ +} diff --git a/src/app/shared/mocks/mock-store.ts b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.html similarity index 100% rename from src/app/shared/mocks/mock-store.ts rename to src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.html diff --git a/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts new file mode 100644 index 0000000000..6ec5be884d --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +/** + * Component for managing the content source of the collection + */ +@Component({ + selector: 'ds-collection-source', + templateUrl: './collection-source.component.html', +}) +export class CollectionSourceComponent { + /* TODO: Implement Collection Edit - Content Source */ +} diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.html b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.html deleted file mode 100644 index c389c681ce..0000000000 --- a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
-
-
- - - {{'collection.edit.delete' - | translate}} -
-
-
diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.scss b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.scss deleted file mode 100644 index 8b13789179..0000000000 --- a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.scss +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.spec.ts b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.spec.ts index 193cb293e4..9f915d2d7a 100644 --- a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.spec.ts +++ b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.spec.ts @@ -13,13 +13,29 @@ describe('EditCollectionPageComponent', () => { let comp: EditCollectionPageComponent; let fixture: ComponentFixture; + const routeStub = { + data: observableOf({ + dso: { payload: {} } + }), + routeConfig: { + children: [] + }, + snapshot: { + firstChild: { + routeConfig: { + path: 'mockUrl' + } + } + } + }; + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], declarations: [EditCollectionPageComponent], providers: [ { provide: CollectionDataService, useValue: {} }, - { provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } }, + { provide: ActivatedRoute, useValue: routeStub }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -31,9 +47,9 @@ describe('EditCollectionPageComponent', () => { fixture.detectChanges(); }); - describe('frontendURL', () => { - it('should have the right frontendURL set', () => { - expect((comp as any).frontendURL).toEqual('/collections/'); + describe('type', () => { + it('should have the right type set', () => { + expect((comp as any).type).toEqual('collection'); }) }); }); diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts index ba70bd26c6..209ce5149a 100644 --- a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts +++ b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts @@ -2,24 +2,30 @@ import { Component } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component'; import { Collection } from '../../core/shared/collection.model'; -import { CollectionDataService } from '../../core/data/collection-data.service'; +import { getCollectionPageRoute } from '../collection-page-routing.module'; /** * Component that represents the page where a user can edit an existing Collection */ @Component({ selector: 'ds-edit-collection', - styleUrls: ['./edit-collection-page.component.scss'], - templateUrl: './edit-collection-page.component.html' + templateUrl: '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.html' }) export class EditCollectionPageComponent extends EditComColPageComponent { - protected frontendURL = '/collections/'; + type = 'collection'; public constructor( - protected collectionDataService: CollectionDataService, protected router: Router, protected route: ActivatedRoute ) { - super(collectionDataService, router, route); + super(router, route); + } + + /** + * Get the collection page url + * @param collection The collection for which the url is requested + */ + getPageUrl(collection: Collection): string { + return getCollectionPageRoute(collection.id) } } diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.module.ts b/src/app/+collection-page/edit-collection-page/edit-collection-page.module.ts new file mode 100644 index 0000000000..f442aae4d6 --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/edit-collection-page.module.ts @@ -0,0 +1,32 @@ +import { NgModule } from '@angular/core'; +import { EditCollectionPageComponent } from './edit-collection-page.component'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../../shared/shared.module'; +import { EditCollectionPageRoutingModule } from './edit-collection-page.routing.module'; +import { CollectionMetadataComponent } from './collection-metadata/collection-metadata.component'; +import { CollectionPageModule } from '../collection-page.module'; +import { CollectionRolesComponent } from './collection-roles/collection-roles.component'; +import { CollectionCurateComponent } from './collection-curate/collection-curate.component'; +import { CollectionSourceComponent } from './collection-source/collection-source.component'; + +/** + * Module that contains all components related to the Edit Collection page administrator functionality + */ +@NgModule({ + imports: [ + CommonModule, + SharedModule, + EditCollectionPageRoutingModule, + CollectionPageModule + ], + declarations: [ + EditCollectionPageComponent, + CollectionMetadataComponent, + CollectionRolesComponent, + CollectionCurateComponent, + CollectionSourceComponent + ] +}) +export class EditCollectionPageModule { + +} diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.routing.module.ts b/src/app/+collection-page/edit-collection-page/edit-collection-page.routing.module.ts new file mode 100644 index 0000000000..fcfced9d81 --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/edit-collection-page.routing.module.ts @@ -0,0 +1,61 @@ +import { RouterModule } from '@angular/router'; +import { NgModule } from '@angular/core'; +import { EditCollectionPageComponent } from './edit-collection-page.component'; +import { CollectionPageResolver } from '../collection-page.resolver'; +import { CollectionMetadataComponent } from './collection-metadata/collection-metadata.component'; +import { CollectionRolesComponent } from './collection-roles/collection-roles.component'; +import { CollectionSourceComponent } from './collection-source/collection-source.component'; +import { CollectionCurateComponent } from './collection-curate/collection-curate.component'; + +/** + * Routing module that handles the routing for the Edit Collection page administrator functionality + */ +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: EditCollectionPageComponent, + resolve: { + dso: CollectionPageResolver + }, + children: [ + { + path: '', + redirectTo: 'metadata', + pathMatch: 'full' + }, + { + path: 'metadata', + component: CollectionMetadataComponent, + data: { + title: 'collection.edit.tabs.metadata.title', + hideReturnButton: true + } + }, + { + path: 'roles', + component: CollectionRolesComponent, + data: { title: 'collection.edit.tabs.roles.title' } + }, + { + path: 'source', + component: CollectionSourceComponent, + data: { title: 'collection.edit.tabs.source.title' } + }, + { + path: 'curate', + component: CollectionCurateComponent, + data: { title: 'collection.edit.tabs.curate.title' } + } + ] + } + ]) + ], + providers: [ + CollectionPageResolver, + ] +}) +export class EditCollectionPageRoutingModule { + +} diff --git a/src/app/+community-page/community-form/community-form.component.ts b/src/app/+community-page/community-form/community-form.component.ts index 17d601e251..1f081bd81b 100644 --- a/src/app/+community-page/community-form/community-form.component.ts +++ b/src/app/+community-page/community-form/community-form.component.ts @@ -1,9 +1,16 @@ import { Component, Input } from '@angular/core'; -import { DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core'; +import { DynamicFormService, DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core'; import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynamic-form-control.model'; import { Community } from '../../core/shared/community.model'; import { ResourceType } from '../../core/shared/resource-type'; import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component'; +import { Location } from '@angular/common'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { CommunityDataService } from '../../core/data/community-data.service'; +import { AuthService } from '../../core/auth/auth.service'; +import { RequestService } from '../../core/data/request.service'; +import { ObjectCacheService } from '../../core/cache/object-cache.service'; /** * Form used for creating and editing communities @@ -22,7 +29,7 @@ export class CommunityFormComponent extends ComColFormComponent { /** * @type {Community.type} This is a community-type form */ - protected type = Community.type; + type = Community.type; /** * The dynamic form fields used for creating/editing a community @@ -57,4 +64,15 @@ export class CommunityFormComponent extends ComColFormComponent { name: 'dc.description.tableofcontents', }), ]; + + public constructor(protected location: Location, + protected formService: DynamicFormService, + protected translate: TranslateService, + protected notificationsService: NotificationsService, + protected authService: AuthService, + protected dsoService: CommunityDataService, + protected requestService: RequestService, + protected objectCache: ObjectCacheService) { + super(location, formService, translate, notificationsService, authService, requestService, objectCache); + } } diff --git a/src/app/+community-page/community-page-routing.module.ts b/src/app/+community-page/community-page-routing.module.ts index cecd17ec10..df548e0617 100644 --- a/src/app/+community-page/community-page-routing.module.ts +++ b/src/app/+community-page/community-page-routing.module.ts @@ -5,7 +5,6 @@ import { CommunityPageComponent } from './community-page.component'; import { CommunityPageResolver } from './community-page.resolver'; import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; -import { EditCommunityPageComponent } from './edit-community-page/edit-community-page.component'; import { CreateCommunityPageGuard } from './create-community-page/create-community-page.guard'; import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; import { URLCombiner } from '../core/url-combiner/url-combiner'; @@ -38,12 +37,8 @@ const COMMUNITY_EDIT_PATH = ':id/edit'; }, { path: COMMUNITY_EDIT_PATH, - pathMatch: 'full', - component: EditCommunityPageComponent, - canActivate: [AuthenticatedGuard], - resolve: { - dso: CommunityPageResolver - } + loadChildren: './edit-community-page/edit-community-page.module#EditCommunityPageModule', + canActivate: [AuthenticatedGuard] }, { path: ':id/delete', diff --git a/src/app/+community-page/community-page.component.html b/src/app/+community-page/community-page.component.html index 05d0bd1d0e..dfd1ce93d9 100644 --- a/src/app/+community-page/community-page.component.html +++ b/src/app/+community-page/community-page.component.html @@ -1,13 +1,13 @@
+
+ + - - - diff --git a/src/app/+community-page/community-page.module.ts b/src/app/+community-page/community-page.module.ts index 6d63cadcc8..1228783c3b 100644 --- a/src/app/+community-page/community-page.module.ts +++ b/src/app/+community-page/community-page.module.ts @@ -6,26 +6,29 @@ import { SharedModule } from '../shared/shared.module'; import { CommunityPageComponent } from './community-page.component'; import { CommunityPageSubCollectionListComponent } from './sub-collection-list/community-page-sub-collection-list.component'; import { CommunityPageRoutingModule } from './community-page-routing.module'; -import {CommunityPageSubCommunityListComponent} from './sub-community-list/community-page-sub-community-list.component'; +import { CommunityPageSubCommunityListComponent } from './sub-community-list/community-page-sub-community-list.component'; import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; import { CommunityFormComponent } from './community-form/community-form.component'; -import { EditCommunityPageComponent } from './edit-community-page/edit-community-page.component'; import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; +import { StatisticsModule } from '../statistics/statistics.module'; @NgModule({ imports: [ CommonModule, SharedModule, - CommunityPageRoutingModule + CommunityPageRoutingModule, + StatisticsModule.forRoot() ], declarations: [ CommunityPageComponent, CommunityPageSubCollectionListComponent, CommunityPageSubCommunityListComponent, CreateCommunityPageComponent, - EditCommunityPageComponent, DeleteCommunityPageComponent, CommunityFormComponent + ], + exports: [ + CommunityFormComponent ] }) diff --git a/src/app/+community-page/create-community-page/create-community-page.component.html b/src/app/+community-page/create-community-page/create-community-page.component.html index 55a080d2a1..4f75771f6d 100644 --- a/src/app/+community-page/create-community-page/create-community-page.component.html +++ b/src/app/+community-page/create-community-page/create-community-page.component.html @@ -7,5 +7,5 @@
- +
diff --git a/src/app/+community-page/create-community-page/create-community-page.component.spec.ts b/src/app/+community-page/create-community-page/create-community-page.component.spec.ts index dead5a5c3b..d0de8ec71c 100644 --- a/src/app/+community-page/create-community-page/create-community-page.component.spec.ts +++ b/src/app/+community-page/create-community-page/create-community-page.component.spec.ts @@ -10,6 +10,8 @@ import { CollectionDataService } from '../../core/data/collection-data.service'; import { of as observableOf } from 'rxjs'; import { CommunityDataService } from '../../core/data/community-data.service'; import { CreateCommunityPageComponent } from './create-community-page.component'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; describe('CreateCommunityPageComponent', () => { let comp: CreateCommunityPageComponent; @@ -23,6 +25,7 @@ describe('CreateCommunityPageComponent', () => { { provide: CommunityDataService, useValue: { findById: () => observableOf({}) } }, { provide: RouteService, useValue: { getQueryParameterValue: () => observableOf('1234') } }, { provide: Router, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/+community-page/create-community-page/create-community-page.component.ts b/src/app/+community-page/create-community-page/create-community-page.component.ts index fd5f18442a..30a2acbb0d 100644 --- a/src/app/+community-page/create-community-page/create-community-page.component.ts +++ b/src/app/+community-page/create-community-page/create-community-page.component.ts @@ -4,6 +4,8 @@ import { CommunityDataService } from '../../core/data/community-data.service'; import { RouteService } from '../../core/services/route.service'; import { Router } from '@angular/router'; import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; /** * Component that represents the page where a user can create a new Community @@ -15,12 +17,15 @@ import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comc }) export class CreateCommunityPageComponent extends CreateComColPageComponent { protected frontendURL = '/communities/'; + protected type = Community.type; public constructor( protected communityDataService: CommunityDataService, protected routeService: RouteService, - protected router: Router + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService ) { - super(communityDataService, communityDataService, routeService, router); + super(communityDataService, communityDataService, routeService, router, notificationsService, translate); } } diff --git a/src/app/+community-page/edit-community-page/community-curate/community-curate.component.html b/src/app/+community-page/edit-community-page/community-curate/community-curate.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/+community-page/edit-community-page/community-curate/community-curate.component.ts b/src/app/+community-page/edit-community-page/community-curate/community-curate.component.ts new file mode 100644 index 0000000000..6151d3fe9a --- /dev/null +++ b/src/app/+community-page/edit-community-page/community-curate/community-curate.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +/** + * Component for managing a community's curation tasks + */ +@Component({ + selector: 'ds-community-curate', + templateUrl: './community-curate.component.html', +}) +export class CommunityCurateComponent { + /* TODO: Implement Community Edit - Curate */ +} diff --git a/src/app/+community-page/edit-community-page/community-metadata/community-metadata.component.html b/src/app/+community-page/edit-community-page/community-metadata/community-metadata.component.html new file mode 100644 index 0000000000..6b441dbabd --- /dev/null +++ b/src/app/+community-page/edit-community-page/community-metadata/community-metadata.component.html @@ -0,0 +1,6 @@ + +{{'community.edit.delete' + | translate}} diff --git a/src/app/+community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts b/src/app/+community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts new file mode 100644 index 0000000000..abeafb4e23 --- /dev/null +++ b/src/app/+community-page/edit-community-page/community-metadata/community-metadata.component.spec.ts @@ -0,0 +1,42 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { SharedModule } from '../../../shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ActivatedRoute } from '@angular/router'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CommunityMetadataComponent } from './community-metadata.component'; +import { CommunityDataService } from '../../../core/data/community-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; + +describe('CommunityMetadataComponent', () => { + let comp: CommunityMetadataComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], + declarations: [CommunityMetadataComponent], + providers: [ + { provide: CommunityDataService, useValue: {} }, + { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: { payload: {} } }) } } }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CommunityMetadataComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('frontendURL', () => { + it('should have the right frontendURL set', () => { + expect((comp as any).frontendURL).toEqual('/communities/'); + }) + }); +}); diff --git a/src/app/+community-page/edit-community-page/community-metadata/community-metadata.component.ts b/src/app/+community-page/edit-community-page/community-metadata/community-metadata.component.ts new file mode 100644 index 0000000000..c4bb88289f --- /dev/null +++ b/src/app/+community-page/edit-community-page/community-metadata/community-metadata.component.ts @@ -0,0 +1,29 @@ +import { Component } from '@angular/core'; +import { ComcolMetadataComponent } from '../../../shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Community } from '../../../core/shared/community.model'; +import { CommunityDataService } from '../../../core/data/community-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * Component for editing a community's metadata + */ +@Component({ + selector: 'ds-community-metadata', + templateUrl: './community-metadata.component.html', +}) +export class CommunityMetadataComponent extends ComcolMetadataComponent { + protected frontendURL = '/communities/'; + protected type = Community.type; + + public constructor( + protected communityDataService: CommunityDataService, + protected router: Router, + protected route: ActivatedRoute, + protected notificationsService: NotificationsService, + protected translate: TranslateService + ) { + super(communityDataService, router, route, notificationsService, translate); + } +} diff --git a/src/app/+community-page/edit-community-page/community-roles/community-roles.component.html b/src/app/+community-page/edit-community-page/community-roles/community-roles.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/+community-page/edit-community-page/community-roles/community-roles.component.ts b/src/app/+community-page/edit-community-page/community-roles/community-roles.component.ts new file mode 100644 index 0000000000..afa1fe14d1 --- /dev/null +++ b/src/app/+community-page/edit-community-page/community-roles/community-roles.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +/** + * Component for managing a community's roles + */ +@Component({ + selector: 'ds-community-roles', + templateUrl: './community-roles.component.html', +}) +export class CommunityRolesComponent { + /* TODO: Implement Community Edit - Roles */ +} diff --git a/src/app/+community-page/edit-community-page/edit-community-page.component.html b/src/app/+community-page/edit-community-page/edit-community-page.component.html deleted file mode 100644 index cedb771c14..0000000000 --- a/src/app/+community-page/edit-community-page/edit-community-page.component.html +++ /dev/null @@ -1,12 +0,0 @@ -
-
-
- - - {{'community.edit.delete' - | translate}} -
-
-
diff --git a/src/app/+community-page/edit-community-page/edit-community-page.component.scss b/src/app/+community-page/edit-community-page/edit-community-page.component.scss deleted file mode 100644 index 8b13789179..0000000000 --- a/src/app/+community-page/edit-community-page/edit-community-page.component.scss +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/+community-page/edit-community-page/edit-community-page.component.spec.ts b/src/app/+community-page/edit-community-page/edit-community-page.component.spec.ts index 54f2133ce7..b61924dd00 100644 --- a/src/app/+community-page/edit-community-page/edit-community-page.component.spec.ts +++ b/src/app/+community-page/edit-community-page/edit-community-page.component.spec.ts @@ -13,13 +13,29 @@ describe('EditCommunityPageComponent', () => { let comp: EditCommunityPageComponent; let fixture: ComponentFixture; + const routeStub = { + data: observableOf({ + dso: { payload: {} } + }), + routeConfig: { + children: [] + }, + snapshot: { + firstChild: { + routeConfig: { + path: 'mockUrl' + } + } + } + }; + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], declarations: [EditCommunityPageComponent], providers: [ { provide: CommunityDataService, useValue: {} }, - { provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } }, + { provide: ActivatedRoute, useValue: routeStub }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -31,9 +47,9 @@ describe('EditCommunityPageComponent', () => { fixture.detectChanges(); }); - describe('frontendURL', () => { - it('should have the right frontendURL set', () => { - expect((comp as any).frontendURL).toEqual('/communities/'); + describe('type', () => { + it('should have the right type set', () => { + expect((comp as any).type).toEqual('community'); }) }); }); diff --git a/src/app/+community-page/edit-community-page/edit-community-page.component.ts b/src/app/+community-page/edit-community-page/edit-community-page.component.ts index 9f49ac49dd..c0adfe0ff1 100644 --- a/src/app/+community-page/edit-community-page/edit-community-page.component.ts +++ b/src/app/+community-page/edit-community-page/edit-community-page.component.ts @@ -1,25 +1,31 @@ import { Component } from '@angular/core'; import { Community } from '../../core/shared/community.model'; -import { CommunityDataService } from '../../core/data/community-data.service'; import { ActivatedRoute, Router } from '@angular/router'; import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component'; +import { getCommunityPageRoute } from '../community-page-routing.module'; /** * Component that represents the page where a user can edit an existing Community */ @Component({ selector: 'ds-edit-community', - styleUrls: ['./edit-community-page.component.scss'], - templateUrl: './edit-community-page.component.html' + templateUrl: '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.html' }) export class EditCommunityPageComponent extends EditComColPageComponent { - protected frontendURL = '/communities/'; + type = 'community'; public constructor( - protected communityDataService: CommunityDataService, protected router: Router, protected route: ActivatedRoute ) { - super(communityDataService, router, route); + super(router, route); + } + + /** + * Get the community page url + * @param community The community for which the url is requested + */ + getPageUrl(community: Community): string { + return getCommunityPageRoute(community.id) } } diff --git a/src/app/+community-page/edit-community-page/edit-community-page.module.ts b/src/app/+community-page/edit-community-page/edit-community-page.module.ts new file mode 100644 index 0000000000..f9a1e11a14 --- /dev/null +++ b/src/app/+community-page/edit-community-page/edit-community-page.module.ts @@ -0,0 +1,30 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../../shared/shared.module'; +import { EditCommunityPageRoutingModule } from './edit-community-page.routing.module'; +import { CommunityPageModule } from '../community-page.module'; +import { EditCommunityPageComponent } from './edit-community-page.component'; +import { CommunityCurateComponent } from './community-curate/community-curate.component'; +import { CommunityMetadataComponent } from './community-metadata/community-metadata.component'; +import { CommunityRolesComponent } from './community-roles/community-roles.component'; + +/** + * Module that contains all components related to the Edit Community page administrator functionality + */ +@NgModule({ + imports: [ + CommonModule, + SharedModule, + EditCommunityPageRoutingModule, + CommunityPageModule + ], + declarations: [ + EditCommunityPageComponent, + CommunityCurateComponent, + CommunityMetadataComponent, + CommunityRolesComponent + ] +}) +export class EditCommunityPageModule { + +} diff --git a/src/app/+community-page/edit-community-page/edit-community-page.routing.module.ts b/src/app/+community-page/edit-community-page/edit-community-page.routing.module.ts new file mode 100644 index 0000000000..1182db2de1 --- /dev/null +++ b/src/app/+community-page/edit-community-page/edit-community-page.routing.module.ts @@ -0,0 +1,55 @@ +import { CommunityPageResolver } from '../community-page.resolver'; +import { EditCommunityPageComponent } from './edit-community-page.component'; +import { RouterModule } from '@angular/router'; +import { NgModule } from '@angular/core'; +import { CommunityMetadataComponent } from './community-metadata/community-metadata.component'; +import { CommunityRolesComponent } from './community-roles/community-roles.component'; +import { CommunityCurateComponent } from './community-curate/community-curate.component'; + +/** + * Routing module that handles the routing for the Edit Community page administrator functionality + */ +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: EditCommunityPageComponent, + resolve: { + dso: CommunityPageResolver + }, + children: [ + { + path: '', + redirectTo: 'metadata', + pathMatch: 'full' + }, + { + path: 'metadata', + component: CommunityMetadataComponent, + data: { + title: 'community.edit.tabs.metadata.title', + hideReturnButton: true + } + }, + { + path: 'roles', + component: CommunityRolesComponent, + data: { title: 'community.edit.tabs.roles.title' } + }, + { + path: 'curate', + component: CommunityCurateComponent, + data: { title: 'community.edit.tabs.curate.title' } + } + ] + } + ]) + ], + providers: [ + CommunityPageResolver, + ] +}) +export class EditCommunityPageRoutingModule { + +} diff --git a/src/app/+home-page/home-page-routing.module.ts b/src/app/+home-page/home-page-routing.module.ts index d7dcc18f49..78da529906 100644 --- a/src/app/+home-page/home-page-routing.module.ts +++ b/src/app/+home-page/home-page-routing.module.ts @@ -2,12 +2,25 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { HomePageComponent } from './home-page.component'; +import { HomePageResolver } from './home-page.resolver'; @NgModule({ imports: [ RouterModule.forChild([ - { path: '', component: HomePageComponent, pathMatch: 'full', data: { title: 'home.title' } } + { + path: '', + component: HomePageComponent, + pathMatch: 'full', + data: {title: 'home.title'}, + resolve: { + site: HomePageResolver + } + } ]) + ], + providers: [ + HomePageResolver ] }) -export class HomePageRoutingModule { } +export class HomePageRoutingModule { +} diff --git a/src/app/+home-page/home-page.component.html b/src/app/+home-page/home-page.component.html index 39ba479033..5515df595b 100644 --- a/src/app/+home-page/home-page.component.html +++ b/src/app/+home-page/home-page.component.html @@ -1,5 +1,8 @@
+ + +
diff --git a/src/app/+home-page/home-page.component.ts b/src/app/+home-page/home-page.component.ts index 902a0e820d..1b915ae683 100644 --- a/src/app/+home-page/home-page.component.ts +++ b/src/app/+home-page/home-page.component.ts @@ -1,9 +1,26 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; +import { map } from 'rxjs/operators'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { Site } from '../core/shared/site.model'; @Component({ selector: 'ds-home-page', styleUrls: ['./home-page.component.scss'], templateUrl: './home-page.component.html' }) -export class HomePageComponent { +export class HomePageComponent implements OnInit { + + site$:Observable; + + constructor( + private route:ActivatedRoute, + ) { + } + + ngOnInit():void { + this.site$ = this.route.data.pipe( + map((data) => data.site as Site), + ); + } } diff --git a/src/app/+home-page/home-page.module.ts b/src/app/+home-page/home-page.module.ts index c0c082b36c..51e978bbfe 100644 --- a/src/app/+home-page/home-page.module.ts +++ b/src/app/+home-page/home-page.module.ts @@ -6,12 +6,14 @@ import { HomePageRoutingModule } from './home-page-routing.module'; import { HomePageComponent } from './home-page.component'; import { TopLevelCommunityListComponent } from './top-level-community-list/top-level-community-list.component'; +import { StatisticsModule } from '../statistics/statistics.module'; @NgModule({ imports: [ CommonModule, SharedModule, - HomePageRoutingModule + HomePageRoutingModule, + StatisticsModule.forRoot() ], declarations: [ HomePageComponent, diff --git a/src/app/+home-page/home-page.resolver.ts b/src/app/+home-page/home-page.resolver.ts new file mode 100644 index 0000000000..1145d1d013 --- /dev/null +++ b/src/app/+home-page/home-page.resolver.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { SiteDataService } from '../core/data/site-data.service'; +import { Site } from '../core/shared/site.model'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; + +/** + * The class that resolve the Site object for a route + */ +@Injectable() +export class HomePageResolver implements Resolve { + constructor(private siteService:SiteDataService) { + } + + /** + * Method for resolving a site object + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable Emits the found Site object, or an error if something went wrong + */ + resolve(route:ActivatedRouteSnapshot, state:RouterStateSnapshot):Observable | Promise | Site { + return this.siteService.find().pipe(take(1)); + } +} diff --git a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts index ed9351d5d2..c8740c35b2 100644 --- a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts @@ -1,15 +1,12 @@ -import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { CommonModule } from '@angular/common'; import { ItemCollectionMapperComponent } from './item-collection-mapper.component'; import { ActivatedRoute, Router } from '@angular/router'; -import { SearchConfigurationService } from '../../../+search-page/search-service/search-configuration.service'; -import { SearchService } from '../../../+search-page/search-service/search.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { ItemDataService } from '../../../core/data/item-data.service'; import { RemoteData } from '../../../core/data/remote-data'; -import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; import { RouterStub } from '../../../shared/testing/router-stub'; @@ -19,7 +16,6 @@ import { SearchServiceStub } from '../../../shared/testing/search-service-stub'; import { PaginatedList } from '../../../core/data/paginated-list'; import { PageInfo } from '../../../core/shared/page-info.model'; import { FormsModule } from '@angular/forms'; -import { SharedModule } from '../../../shared/shared.module'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; import { HostWindowService } from '../../../shared/host-window.service'; @@ -28,7 +24,6 @@ import { By } from '@angular/platform-browser'; import { Item } from '../../../core/shared/item.model'; import { ObjectSelectService } from '../../../shared/object-select/object-select.service'; import { ObjectSelectServiceStub } from '../../../shared/testing/object-select-service-stub'; -import { Observable } from 'rxjs/internal/Observable'; import { of } from 'rxjs/internal/observable/of'; import { RestResponse } from '../../../core/cache/response.models'; import { CollectionSelectComponent } from '../../../shared/object-select/collection-select/collection-select.component'; @@ -39,6 +34,9 @@ import { SearchFormComponent } from '../../../shared/search-form/search-form.com import { Collection } from '../../../core/shared/collection.model'; import { ErrorComponent } from '../../../shared/error/error.component'; import { LoadingComponent } from '../../../shared/loading/loading.component'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; describe('ItemCollectionMapperComponent', () => { let comp: ItemCollectionMapperComponent; diff --git a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts index 97b8164a6e..5494d5ab5f 100644 --- a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts +++ b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts @@ -2,15 +2,12 @@ import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { ChangeDetectionStrategy, Component, OnInit, ViewChild } from '@angular/core'; import { fadeIn, fadeInOut } from '../../../shared/animations/fade'; -import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; import { Collection } from '../../../core/shared/collection.model'; import { Item } from '../../../core/shared/item.model'; import { getRemoteDataPayload, getSucceededRemoteData, toDSpaceObjectListRD } from '../../../core/shared/operators'; import { ActivatedRoute, Router } from '@angular/router'; -import { SearchService } from '../../../+search-page/search-service/search.service'; -import { SearchConfigurationService } from '../../../+search-page/search-service/search-configuration.service'; import { map, startWith, switchMap, take } from 'rxjs/operators'; import { ItemDataService } from '../../../core/data/item-data.service'; import { TranslateService } from '@ngx-translate/core'; @@ -19,6 +16,9 @@ import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model' import { isNotEmpty } from '../../../shared/empty.util'; import { RestResponse } from '../../../core/cache/response.models'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { SearchService } from '../../../core/shared/search/search.service'; @Component({ selector: 'ds-item-collection-mapper', diff --git a/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts b/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts index e73b4b6f9a..aa84b160a0 100644 --- a/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts @@ -9,7 +9,6 @@ import { ActivatedRoute, Router } from '@angular/router'; import { ItemMoveComponent } from './item-move.component'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { SearchService } from '../../../+search-page/search-service/search.service'; import { of as observableOf } from 'rxjs'; import { FormsModule } from '@angular/forms'; import { ItemDataService } from '../../../core/data/item-data.service'; @@ -18,6 +17,7 @@ import { PaginatedList } from '../../../core/data/paginated-list'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { RestResponse } from '../../../core/cache/response.models'; import { Collection } from '../../../core/shared/collection.model'; +import { SearchService } from '../../../core/shared/search/search.service'; describe('ItemMoveComponent', () => { let comp: ItemMoveComponent; diff --git a/src/app/+item-page/edit-item-page/item-move/item-move.component.ts b/src/app/+item-page/edit-item-page/item-move/item-move.component.ts index 113ee97b3f..4db7cf94da 100644 --- a/src/app/+item-page/edit-item-page/item-move/item-move.component.ts +++ b/src/app/+item-page/edit-item-page/item-move/item-move.component.ts @@ -1,12 +1,9 @@ import { Component, OnInit } from '@angular/core'; -import { SearchService } from '../../../+search-page/search-service/search.service'; import { first, map } from 'rxjs/operators'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; -import { SearchOptions } from '../../../+search-page/search-options.model'; import { RemoteData } from '../../../core/data/remote-data'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { PaginatedList } from '../../../core/data/paginated-list'; -import { SearchResult } from '../../../+search-page/search-result.model'; import { Item } from '../../../core/shared/item.model'; import { ActivatedRoute, Router } from '@angular/router'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; @@ -17,9 +14,10 @@ import { getItemEditPath } from '../../item-page-routing.module'; import { Observable, of as observableOf } from 'rxjs'; import { RestResponse } from '../../../core/cache/response.models'; import { Collection } from '../../../core/shared/collection.model'; -import { tap } from 'rxjs/internal/operators/tap'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.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'; @Component({ selector: 'ds-item-move', diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts index 24016c3671..d0de197d9f 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts @@ -12,8 +12,6 @@ import { PaginatedList } from '../../../../core/data/paginated-list'; import { PageInfo } from '../../../../core/shared/page-info.model'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; -import {Store} from '@ngrx/store'; -import {CoreState} from '../../../../core/core.reducers'; let objectUpdatesService; const url = 'http://test-url.com/test-url'; @@ -95,9 +93,11 @@ describe('EditRelationshipComponent', () => { itemSelection[relatedItem.uuid] = false; itemSelection[item.uuid] = true; - const store = new Store(undefined, undefined, undefined); - - objectUpdatesService = new ObjectUpdatesService(store); + objectUpdatesService = { + isSelectedVirtualMetadata: () => null, + removeSingleFieldUpdate: () => null, + saveRemoveFieldUpdate: () => null, + }; spyOn(objectUpdatesService, 'isSelectedVirtualMetadata').and.callFake((a, b, uuid) => observableOf(itemSelection[uuid])); @@ -178,18 +178,18 @@ describe('EditRelationshipComponent', () => { }); it('should close the virtual metadata modal and call saveRemoveFieldUpdate with the correct arguments', () => { - expect(comp.closeVirtualMetadataModal).toHaveBeenCalled(); - expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, Object.assign({}, fieldUpdate1.field, { - keepLeftVirtualMetadata: false, - keepRightVirtualMetadata: true, - })); + fixture.whenStable(() => { + expect(comp.closeVirtualMetadataModal).toHaveBeenCalled(); + expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, Object.assign({}, fieldUpdate1.field, { + keepLeftVirtualMetadata: false, + keepRightVirtualMetadata: true, + })); + }); }); - }); describe('undo', () => { beforeEach(() => { - spyOn(objectUpdatesService, 'removeSingleFieldUpdate'); comp.undo(); comp.ngOnChanges(); }); @@ -197,7 +197,8 @@ describe('EditRelationshipComponent', () => { it('should call removeSingleFieldUpdate with the correct arguments', () => { fixture.whenStable().then(() => { - expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, fieldUpdate1[0]); + expect(spyOn(objectUpdatesService, 'removeSingleFieldUpdate')) + .toHaveBeenCalledWith(url, fieldUpdate1[0]); }) }); }); diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts index 4ac3f05a40..6c0f340d65 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts @@ -1,31 +1,32 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { ItemRelationshipsComponent } from './item-relationships.component'; -import { ChangeDetectorRef, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; -import { INotification, Notification } from '../../../shared/notifications/models/notification.model'; -import { NotificationType } from '../../../shared/notifications/models/notification-type'; -import { RouterStub } from '../../../shared/testing/router-stub'; -import { TestScheduler } from 'rxjs/testing'; -import { SharedModule } from '../../../shared/shared.module'; -import { TranslateModule } from '@ngx-translate/core'; -import { ItemDataService } from '../../../core/data/item-data.service'; -import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; -import { ActivatedRoute, Router } from '@angular/router'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { GLOBAL_CONFIG } from '../../../../config'; -import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model'; -import { ResourceType } from '../../../core/shared/resource-type'; -import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; -import { of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; -import { RemoteData } from '../../../core/data/remote-data'; -import { Item } from '../../../core/shared/item.model'; -import { PaginatedList } from '../../../core/data/paginated-list'; -import { PageInfo } from '../../../core/shared/page-info.model'; -import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; -import { RelationshipService } from '../../../core/data/relationship.service'; -import { ObjectCacheService } from '../../../core/cache/object-cache.service'; -import { getTestScheduler } from 'jasmine-marbles'; -import { RestResponse } from '../../../core/cache/response.models'; -import { RequestService } from '../../../core/data/request.service'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {ItemRelationshipsComponent} from './item-relationships.component'; +import {ChangeDetectorRef, DebugElement, NO_ERRORS_SCHEMA} from '@angular/core'; +import {INotification, Notification} from '../../../shared/notifications/models/notification.model'; +import {NotificationType} from '../../../shared/notifications/models/notification-type'; +import {RouterStub} from '../../../shared/testing/router-stub'; +import {TestScheduler} from 'rxjs/testing'; +import {SharedModule} from '../../../shared/shared.module'; +import {TranslateModule} from '@ngx-translate/core'; +import {ItemDataService} from '../../../core/data/item-data.service'; +import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service'; +import {ActivatedRoute, Router} from '@angular/router'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {GLOBAL_CONFIG} from '../../../../config'; +import {RelationshipType} from '../../../core/shared/item-relationships/relationship-type.model'; +import {Relationship} from '../../../core/shared/item-relationships/relationship.model'; +import {combineLatest as observableCombineLatest, of as observableOf} from 'rxjs'; +import {RemoteData} from '../../../core/data/remote-data'; +import {Item} from '../../../core/shared/item.model'; +import {PaginatedList} from '../../../core/data/paginated-list'; +import {PageInfo} from '../../../core/shared/page-info.model'; +import {FieldChangeType} from '../../../core/data/object-updates/object-updates.actions'; +import {RelationshipService} from '../../../core/data/relationship.service'; +import {ObjectCacheService} from '../../../core/cache/object-cache.service'; +import {getTestScheduler} from 'jasmine-marbles'; +import {RestResponse} from '../../../core/cache/response.models'; +import {RequestService} from '../../../core/data/request.service'; +import {EntityTypeService} from '../../../core/data/entity-type.service'; +import {ItemType} from '../../../core/shared/item-relationships/item-type.model'; let comp: any; let fixture: ComponentFixture; @@ -34,6 +35,7 @@ let el: HTMLElement; let objectUpdatesService; let relationshipService; let requestService; +let entityTypeService; let objectCache; const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); @@ -58,6 +60,7 @@ let author1; let author2; let fieldUpdate1; let fieldUpdate2; +let entityType; let relationships; let relationshipType; @@ -95,6 +98,10 @@ describe('ItemRelationshipsComponent', () => { lastModified: date }); + entityType = Object.assign(new ItemType(), { + id: 'entityType', + }); + author1 = Object.assign(new Item(), { id: 'author1', uuid: 'author1' @@ -159,7 +166,9 @@ describe('ItemRelationshipsComponent', () => { getRelatedItemsByLabel: observableOf([author1, author2]), getItemRelationshipsArray: observableOf(relationships), deleteRelationship: observableOf(new RestResponse(true, 200, 'OK')), - getItemResolvedRelatedItemsAndRelationships: observableCombineLatest(observableOf([author1, author2]), observableOf([item, item]), observableOf(relationships)) + getItemResolvedRelatedItemsAndRelationships: observableCombineLatest(observableOf([author1, author2]), observableOf([item, item]), observableOf(relationships)), + getRelationshipsByRelatedItemIds: observableOf(relationships), + getRelationshipTypeLabelsByItem: observableOf([relationshipType.leftwardType]) } ); @@ -174,6 +183,25 @@ describe('ItemRelationshipsComponent', () => { remove: undefined }); + entityTypeService = jasmine.createSpyObj('entityTypeService', + { + getEntityTypeByLabel: observableOf(new RemoteData( + false, + false, + true, + null, + entityType, + )), + getEntityTypeRelationships: observableOf(new RemoteData( + false, + false, + true, + null, + new PaginatedList(new PageInfo(), [relationshipType]), + )), + } + ); + scheduler = getTestScheduler(); TestBed.configureTestingModule({ imports: [SharedModule, TranslateModule.forRoot()], @@ -186,6 +214,7 @@ describe('ItemRelationshipsComponent', () => { { provide: NotificationsService, useValue: notificationsService }, { provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any }, { provide: RelationshipService, useValue: relationshipService }, + { provide: EntityTypeService, useValue: entityTypeService }, { provide: ObjectCacheService, useValue: objectCache }, { provide: RequestService, useValue: requestService }, ChangeDetectorRef diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts index 2030b07f50..b65dd60c4c 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts @@ -1,33 +1,29 @@ -import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core'; -import { Item } from '../../../core/shared/item.model'; -import { - DeleteRelationship, - FieldUpdate, - FieldUpdates -} from '../../../core/data/object-updates/object-updates.reducer'; -import { Observable } from 'rxjs/internal/Observable'; -import {filter, map, switchMap, take, tap} from 'rxjs/operators'; -import { combineLatest as observableCombineLatest, zip as observableZip } from 'rxjs'; -import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; -import { ItemDataService } from '../../../core/data/item-data.service'; -import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; -import { ActivatedRoute, Router } from '@angular/router'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; -import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; -import { RelationshipService } from '../../../core/data/relationship.service'; -import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; -import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; -import { ErrorResponse, RestResponse } from '../../../core/cache/response.models'; -import { isNotEmptyOperator } from '../../../shared/empty.util'; -import { RemoteData } from '../../../core/data/remote-data'; -import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import {ChangeDetectorRef, Component, Inject, OnDestroy} from '@angular/core'; +import {Item} from '../../../core/shared/item.model'; +import {DeleteRelationship, FieldUpdate, FieldUpdates} from '../../../core/data/object-updates/object-updates.reducer'; +import {Observable} from 'rxjs/internal/Observable'; +import {filter, map, switchMap, take} from 'rxjs/operators'; +import {zip as observableZip} from 'rxjs'; +import {AbstractItemUpdateComponent} from '../abstract-item-update/abstract-item-update.component'; +import {ItemDataService} from '../../../core/data/item-data.service'; +import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service'; +import {ActivatedRoute, Router} from '@angular/router'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {TranslateService} from '@ngx-translate/core'; +import {GLOBAL_CONFIG, GlobalConfig} from '../../../../config'; +import {RelationshipService} from '../../../core/data/relationship.service'; +import {ErrorResponse, RestResponse} from '../../../core/cache/response.models'; +import {RemoteData} from '../../../core/data/remote-data'; +import {ObjectCacheService} from '../../../core/cache/object-cache.service'; import {getRemoteDataPayload, getSucceededRemoteData} from '../../../core/shared/operators'; -import { RequestService } from '../../../core/data/request.service'; -import { Subscription } from 'rxjs/internal/Subscription'; +import {RequestService} from '../../../core/data/request.service'; +import {Subscription} from 'rxjs/internal/Subscription'; import {RelationshipType} from '../../../core/shared/item-relationships/relationship-type.model'; import {ItemType} from '../../../core/shared/item-relationships/item-type.model'; import {EntityTypeService} from '../../../core/data/entity-type.service'; +import {isNotEmptyOperator} from '../../../shared/empty.util'; +import {FieldChangeType} from '../../../core/data/object-updates/object-updates.actions'; +import {Relationship} from '../../../core/shared/item-relationships/relationship.model'; @Component({ selector: 'ds-item-relationships', @@ -63,6 +59,7 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl protected objectCache: ObjectCacheService, protected requestService: RequestService, protected entityTypeService: EntityTypeService, + protected cdr: ChangeDetectorRef, ) { super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); } @@ -78,6 +75,7 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl getSucceededRemoteData(), ).subscribe((itemRD: RemoteData) => { this.item = itemRD.payload; + this.cdr.detectChanges(); this.initializeUpdates(); }); } @@ -118,7 +116,7 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl */ public submit(): void { // Get all the relationships that should be removed - const removedRelationshipIDs$ = this.relationshipService.getItemRelationshipsArray(this.item).pipe( + this.relationshipService.getItemRelationshipsArray(this.item).pipe( map((relationships: Relationship[]) => relationships.map((relationship) => Object.assign(new Relationship(), relationship, {uuid: relationship.id}) )), @@ -131,8 +129,6 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl .map((fieldUpdate: FieldUpdate) => fieldUpdate.field as DeleteRelationship) ), isNotEmptyOperator(), - ); - removedRelationshipIDs$.pipe( take(1), switchMap((deleteRelationships: DeleteRelationship[]) => observableZip(...deleteRelationships.map((deleteRelationship) => { diff --git a/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html index f5dab71884..15bcce2e1f 100644 --- a/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html +++ b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html @@ -11,7 +11,7 @@ class="d-flex flex-row">
diff --git a/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.spec.ts b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.spec.ts index 5b72bec46d..98b4e2e044 100644 --- a/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.spec.ts +++ b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.spec.ts @@ -1,4 +1,4 @@ -import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; import {of as observableOf} from 'rxjs/internal/observable/of'; import {TranslateModule} from '@ngx-translate/core'; import {DebugElement, NO_ERRORS_SCHEMA} from '@angular/core'; @@ -8,7 +8,7 @@ import {Item} from '../../../core/shared/item.model'; import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service'; import {VarDirective} from '../../../shared/utils/var.directive'; -fdescribe('VirtualMetadataComponent', () => { +describe('VirtualMetadataComponent', () => { let comp: VirtualMetadataComponent; let fixture: ComponentFixture; @@ -50,6 +50,7 @@ fdescribe('VirtualMetadataComponent', () => { NO_ERRORS_SCHEMA ] }).compileComponents(); + fixture = TestBed.createComponent(VirtualMetadataComponent); comp = fixture.componentInstance; de = fixture.debugElement; diff --git a/src/app/+item-page/full/full-item-page.component.html b/src/app/+item-page/full/full-item-page.component.html index 7aec57da0c..c453df6bff 100644 --- a/src/app/+item-page/full/full-item-page.component.html +++ b/src/app/+item-page/full/full-item-page.component.html @@ -1,6 +1,7 @@
+
- - + +
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts index a475e16637..39d7d9ccce 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts @@ -13,6 +13,7 @@ import { isNotEmpty } from '../../../../shared/empty.util'; import { JournalComponent } from './journal.component'; import { GenericItemPageFieldComponent } from '../../../../+item-page/simple/field-components/specific-field/generic/generic-item-page-field.component'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { RelationshipService } from '../../../../core/data/relationship.service'; let comp: JournalComponent; let fixture: ComponentFixture; @@ -53,7 +54,8 @@ describe('JournalComponent', () => { declarations: [JournalComponent, GenericItemPageFieldComponent, TruncatePipe], providers: [ {provide: ItemDataService, useValue: {}}, - {provide: TruncatableService, useValue: {}} + {provide: TruncatableService, useValue: {}}, + {provide: RelationshipService, useValue: {}} ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts index dbbeb81662..605bd52238 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { ItemComponent } from '../../../../+item-page/simple/item-types/shared/item.component'; -import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @listableObjectComponent('Journal', ViewMode.StandalonePage) @Component({ 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 4d97868b58..1b23d567f5 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 @@ -24,16 +24,6 @@
- - - -
+
+ + +
diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts index d9d4461bfa..6df2d87503 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { ItemComponent } from '../../../../+item-page/simple/item-types/shared/item.component'; -import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @listableObjectComponent('OrgUnit', ViewMode.StandalonePage) @Component({ diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.html b/src/app/entity-groups/research-entities/item-pages/person/person.component.html index ff675ab057..97a3cf416e 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.html +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.html @@ -53,8 +53,11 @@
- - + +
diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts index 15c7184702..9972736b95 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { ItemComponent } from '../../../../+item-page/simple/item-types/shared/item.component'; -import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @listableObjectComponent('Person', ViewMode.StandalonePage) @Component({ diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.ts b/src/app/entity-groups/research-entities/item-pages/project/project.component.ts index 8ac424af5b..4e432e869e 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { ItemComponent } from '../../../../+item-page/simple/item-types/shared/item.component'; -import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @listableObjectComponent('Project', ViewMode.StandalonePage) @Component({ diff --git a/src/app/entity-groups/research-entities/research-entities.module.ts b/src/app/entity-groups/research-entities/research-entities.module.ts index 8829318f34..86c2a375da 100644 --- a/src/app/entity-groups/research-entities/research-entities.module.ts +++ b/src/app/entity-groups/research-entities/research-entities.module.ts @@ -20,6 +20,11 @@ import { OrgUnitSearchResultGridElementComponent } from './item-grid-elements/se import { ProjectSearchResultGridElementComponent } from './item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component'; import { PersonItemMetadataListElementComponent } from './metadata-representations/person/person-item-metadata-list-element.component'; import { OrgUnitItemMetadataListElementComponent } from './metadata-representations/org-unit/org-unit-item-metadata-list-element.component'; +import { PersonSearchResultListSubmissionElementComponent } from './submission/item-list-elements/person/person-search-result-list-submission-element.component'; +import { PersonInputSuggestionsComponent } from './submission/item-list-elements/person/person-suggestions/person-input-suggestions.component'; +import { NameVariantModalComponent } from './submission/name-variant-modal/name-variant-modal.component'; +import { OrgUnitInputSuggestionsComponent } from './submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component'; +import { OrgUnitSearchResultListSubmissionElementComponent } from './submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component'; const ENTRY_COMPONENTS = [ OrgUnitComponent, @@ -38,7 +43,12 @@ const ENTRY_COMPONENTS = [ ProjectSearchResultListElementComponent, PersonSearchResultGridElementComponent, OrgUnitSearchResultGridElementComponent, - ProjectSearchResultGridElementComponent + ProjectSearchResultGridElementComponent, + PersonSearchResultListSubmissionElementComponent, + PersonInputSuggestionsComponent, + NameVariantModalComponent, + OrgUnitSearchResultListSubmissionElementComponent, + OrgUnitInputSuggestionsComponent ]; @NgModule({ @@ -49,7 +59,7 @@ const ENTRY_COMPONENTS = [ ItemPageModule ], declarations: [ - ...ENTRY_COMPONENTS + ...ENTRY_COMPONENTS, ], entryComponents: [ ...ENTRY_COMPONENTS 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 new file mode 100644 index 0000000000..b0fa714371 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html @@ -0,0 +1,19 @@ +
+
+ +
+
+ + + + , + + + + + +
+
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.scss b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.scss new file mode 100644 index 0000000000..8fc6d2138d --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.scss @@ -0,0 +1,7 @@ +@import '../../../../../../styles/variables'; + +$submission-relationship-thumbnail-width: 80px; + +.person-thumbnail { + width: $submission-relationship-thumbnail-width; +} \ No newline at end of file diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.spec.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.spec.ts new file mode 100644 index 0000000000..2a77b64f43 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.spec.ts @@ -0,0 +1,154 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { of as observableOf } from 'rxjs'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { OrgUnitSearchResultListSubmissionElementComponent } from './org-unit-search-result-list-submission-element.component'; +import { Item } from '../../../../../core/shared/item.model'; +import { TruncatePipe } from '../../../../../shared/utils/truncate.pipe'; +import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { NotificationsService } from '../../../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ItemDataService } from '../../../../../core/data/item-data.service'; +import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service'; +import { Store } from '@ngrx/store'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../../core/data/paginated-list'; + +let personListElementComponent: OrgUnitSearchResultListSubmissionElementComponent; +let fixture: ComponentFixture; + +let mockItemWithMetadata: ItemSearchResult; +let mockItemWithoutMetadata: ItemSearchResult; + +let nameVariant; +let mockRelationshipService; + +function init() { + mockItemWithMetadata = Object.assign( + new ItemSearchResult(), + { + indexableObject: Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(new PaginatedList(undefined, [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'organization.address.addressLocality': [ + { + language: 'en_US', + value: 'Europe' + } + ], + 'organization.address.addressCountry': [ + { + language: 'en_US', + value: 'Belgium' + } + ] + } + }) + }); + mockItemWithoutMetadata = Object.assign( + new ItemSearchResult(), + { + indexableObject: Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(new PaginatedList(undefined, [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } + }) + }); + + nameVariant = 'Doe J.'; + mockRelationshipService = { + getNameVariant: () => observableOf(nameVariant) + }; +} + +describe('OrgUnitSearchResultListSubmissionElementComponent', () => { + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [OrgUnitSearchResultListSubmissionElementComponent, TruncatePipe], + providers: [ + { provide: TruncatableService, useValue: {} }, + { provide: RelationshipService, useValue: mockRelationshipService }, + { provide: NotificationsService, useValue: {} }, + { provide: TranslateService, useValue: {} }, + { provide: NgbModal, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + { provide: SelectableListService, useValue: {} }, + { provide: Store, useValue: {} } + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(OrgUnitSearchResultListSubmissionElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(OrgUnitSearchResultListSubmissionElementComponent); + personListElementComponent = fixture.componentInstance; + + })); + + describe('When the item has a address locality span', () => { + beforeEach(() => { + personListElementComponent.object = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the address locality span', () => { + const jobTitleField = fixture.debugElement.query(By.css('span.item-list-address-locality')); + expect(jobTitleField).not.toBeNull(); + }); + }); + + describe('When the item has no address locality', () => { + beforeEach(() => { + personListElementComponent.object = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the address locality span', () => { + const jobTitleField = fixture.debugElement.query(By.css('span.item-list-address-locality')); + expect(jobTitleField).toBeNull(); + }); + }); + + describe('When the item has a address country span', () => { + beforeEach(() => { + personListElementComponent.object = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the address country span', () => { + const jobTitleField = fixture.debugElement.query(By.css('span.item-list-address-country')); + expect(jobTitleField).not.toBeNull(); + }); + }); + + describe('When the item has no address country', () => { + beforeEach(() => { + personListElementComponent.object = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the address country span', () => { + const jobTitleField = fixture.debugElement.query(By.css('span.item-list-address-country')); + expect(jobTitleField).toBeNull(); + }); + }); +}); diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts new file mode 100644 index 0000000000..cbddb8d6f9 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts @@ -0,0 +1,98 @@ +import { Component, OnInit } from '@angular/core'; +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'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { Item } from '../../../../../core/shared/item.model'; +import { Context } from '../../../../../core/shared/context.model'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; +import { take } from 'rxjs/operators'; +import { NotificationsService } from '../../../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { MetadataValue } from '../../../../../core/shared/metadata.models'; +import { ItemDataService } from '../../../../../core/data/item-data.service'; +import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service'; +import { NameVariantModalComponent } from '../../name-variant-modal/name-variant-modal.component'; + +@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.SubmissionModal) +@Component({ + selector: 'ds-person-search-result-list-submission-element', + styleUrls: ['./org-unit-search-result-list-submission-element.component.scss'], + templateUrl: './org-unit-search-result-list-submission-element.component.html' +}) + +/** + * The component for displaying a list element for an item search result of the type OrgUnit + */ +export class OrgUnitSearchResultListSubmissionElementComponent extends SearchResultListElementComponent implements OnInit { + allSuggestions: string[]; + selectedName: string; + alternativeField = 'dc.title.alternative'; + + constructor(protected truncatableService: TruncatableService, + private relationshipService: RelationshipService, + private notificationsService: NotificationsService, + private translateService: TranslateService, + private modalService: NgbModal, + private itemDataService: ItemDataService, + private selectableListService: SelectableListService) { + super(truncatableService); + } + + ngOnInit() { + super.ngOnInit(); + const defaultValue = this.firstMetadataValue('organization.legalName'); + const alternatives = this.allMetadataValues(this.alternativeField); + this.allSuggestions = [defaultValue, ...alternatives]; + + this.relationshipService.getNameVariant(this.listID, this.dso.uuid) + .pipe(take(1)) + .subscribe((nameVariant: string) => { + this.selectedName = nameVariant || defaultValue; + } + ); + } + + select(value) { + this.selectableListService.isObjectSelected(this.listID, this.object) + .pipe(take(1)) + .subscribe((selected) => { + if (!selected) { + this.selectableListService.selectSingle(this.listID, this.object); + } + }); + this.relationshipService.setNameVariant(this.listID, this.dso.uuid, value); + } + + selectCustom(value) { + if (!this.allSuggestions.includes(value)) { + this.openModal(value) + .then(() => { + + const newName: MetadataValue = new MetadataValue(); + newName.value = value; + + const existingNames: MetadataValue[] = this.dso.metadata[this.alternativeField] || []; + const alternativeNames = { [this.alternativeField]: [...existingNames, newName] }; + const updatedItem = + Object.assign({}, this.dso, { + metadata: { + ...this.dso.metadata, + ...alternativeNames + }, + }); + this.itemDataService.update(updatedItem).pipe(take(1)).subscribe(); + }) + } + this.select(value); + } + + openModal(value): Promise { + const modalRef = this.modalService.open(NameVariantModalComponent, { centered: true }); + const modalComp = modalRef.componentInstance; + modalComp.value = value; + return modalRef.result; + } +} 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 new file mode 100644 index 0000000000..e177b2b561 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html @@ -0,0 +1,24 @@ +
+ + + +
\ No newline at end of file diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.scss b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.scss new file mode 100644 index 0000000000..8301e12c5f --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.scss @@ -0,0 +1,18 @@ +form { + z-index: 1; + &:before { + position: absolute; + font-weight: 900; + font-family: "Font Awesome 5 Free"; + content: "\f0d7"; + top: 7px; + right: 0; + height: 20px; + width: 20px; + z-index: -1; + } + + input.suggestion_input { + background: transparent; + } +} \ No newline at end of file diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.spec.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.spec.ts new file mode 100644 index 0000000000..34b89cc8aa --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.spec.ts @@ -0,0 +1,64 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { OrgUnitInputSuggestionsComponent } from './org-unit-input-suggestions.component'; +import { FormsModule } from '@angular/forms'; + +let component: OrgUnitInputSuggestionsComponent; +let fixture: ComponentFixture; + +let suggestions: string[]; +let testValue; + +function init() { + suggestions = ['test', 'suggestion', 'example'] + testValue = 'bla'; +} + +describe('OrgUnitInputSuggestionsComponent', () => { + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [OrgUnitInputSuggestionsComponent], + imports: [ + FormsModule, + ], + providers: [ + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(OrgUnitInputSuggestionsComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(OrgUnitInputSuggestionsComponent); + component = fixture.componentInstance; + component.suggestions = suggestions; + fixture.detectChanges(); + })); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('When the component is initialized', () => { + it('should set the value to the first value of the suggestions', () => { + expect(component.value).toEqual('test'); + }); + }); + + describe('When onSubmit is called', () => { + it('should set the value to parameter of the method', () => { + component.onSubmit(testValue); + expect(component.value).toEqual(testValue); + }); + }); + + describe('When onClickSuggestion is called', () => { + it('should set the value to parameter of the method', () => { + component.onClickSuggestion(testValue); + expect(component.value).toEqual(testValue); + }); + }); + +}); diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.ts new file mode 100644 index 0000000000..c868281e00 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.ts @@ -0,0 +1,48 @@ +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { InputSuggestionsComponent } from '../../../../../../shared/input-suggestions/input-suggestions.component'; + +@Component({ + selector: 'ds-org-unit-input-suggestions', + styleUrls: ['./org-unit-input-suggestions.component.scss', './../../../../../../shared/input-suggestions/input-suggestions.component.scss'], + templateUrl: './org-unit-input-suggestions.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + // Usage of forwardRef necessary https://github.com/angular/angular.io/issues/1151 + // tslint:disable-next-line:no-forward-ref + useExisting: forwardRef(() => OrgUnitInputSuggestionsComponent), + multi: true + } + ] +}) + +/** + * Component representing a form with a autocomplete functionality + */ +export class OrgUnitInputSuggestionsComponent extends InputSuggestionsComponent implements OnInit { + /** + * The suggestions that should be shown + */ + @Input() suggestions: string[] = []; + + ngOnInit() { + if (this.suggestions.length > 0) { + this.value = this.suggestions[0] + } + } + + onSubmit(data) { + this.value = data; + this.submitSuggestion.emit(data); + } + + onClickSuggestion(data) { + this.value = data; + this.clickSuggestion.emit(data); + this.close(); + this.blockReopen = true; + this.queryInput.nativeElement.focus(); + return false; + } +} diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html new file mode 100644 index 0000000000..df93c2f4f3 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html @@ -0,0 +1,16 @@ +
+
+ +
+
+ + + + + + + + +
+
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.scss b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.scss new file mode 100644 index 0000000000..8fc6d2138d --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.scss @@ -0,0 +1,7 @@ +@import '../../../../../../styles/variables'; + +$submission-relationship-thumbnail-width: 80px; + +.person-thumbnail { + width: $submission-relationship-thumbnail-width; +} \ 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.spec.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.spec.ts new file mode 100644 index 0000000000..a21f0ec075 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.spec.ts @@ -0,0 +1,124 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { of as observableOf } from 'rxjs'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { PersonSearchResultListSubmissionElementComponent } from './person-search-result-list-submission-element.component'; +import { Item } from '../../../../../core/shared/item.model'; +import { TruncatePipe } from '../../../../../shared/utils/truncate.pipe'; +import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { NotificationsService } from '../../../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ItemDataService } from '../../../../../core/data/item-data.service'; +import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service'; +import { Store } from '@ngrx/store'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../../core/data/paginated-list'; + +let personListElementComponent: PersonSearchResultListSubmissionElementComponent; +let fixture: ComponentFixture; + +let mockItemWithMetadata: ItemSearchResult; +let mockItemWithoutMetadata: ItemSearchResult; + +let nameVariant; +let mockRelationshipService; + +function init() { + mockItemWithMetadata = Object.assign( + new ItemSearchResult(), + { + indexableObject: Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(new PaginatedList(undefined, [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'person.jobTitle': [ + { + language: 'en_US', + value: 'Developer' + } + ] + } + }) + }); + mockItemWithoutMetadata = Object.assign( + new ItemSearchResult(), + { + indexableObject: Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(new PaginatedList(undefined, [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } + }) + }); + + nameVariant = 'Doe J.'; + mockRelationshipService = { + getNameVariant: () => observableOf(nameVariant) + }; +} + +describe('PersonSearchResultListElementSubmissionComponent', () => { + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [PersonSearchResultListSubmissionElementComponent, TruncatePipe], + providers: [ + { provide: TruncatableService, useValue: {} }, + { provide: RelationshipService, useValue: mockRelationshipService }, + { provide: NotificationsService, useValue: {} }, + { provide: TranslateService, useValue: {} }, + { provide: NgbModal, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + { provide: SelectableListService, useValue: {} }, + { provide: Store, useValue: {}} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(PersonSearchResultListSubmissionElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(PersonSearchResultListSubmissionElementComponent); + personListElementComponent = fixture.componentInstance; + + })); + + describe('When the item has a job title', () => { + beforeEach(() => { + personListElementComponent.object = mockItemWithMetadata; + fixture.detectChanges(); + }); + + it('should show the job title span', () => { + const jobTitleField = fixture.debugElement.query(By.css('span.item-list-job-title')); + expect(jobTitleField).not.toBeNull(); + }); + }); + + describe('When the item has no job title', () => { + beforeEach(() => { + personListElementComponent.object = mockItemWithoutMetadata; + fixture.detectChanges(); + }); + + it('should not show the job title span', () => { + const jobTitleField = fixture.debugElement.query(By.css('span.item-list-job-title')); + expect(jobTitleField).toBeNull(); + }); + }); +}); 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 new file mode 100644 index 0000000000..37fd77649b --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts @@ -0,0 +1,98 @@ +import { Component, OnInit } from '@angular/core'; +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'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { Item } from '../../../../../core/shared/item.model'; +import { Context } from '../../../../../core/shared/context.model'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; +import { take } from 'rxjs/operators'; +import { NotificationsService } from '../../../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { NameVariantModalComponent } from '../../name-variant-modal/name-variant-modal.component'; +import { MetadataValue } from '../../../../../core/shared/metadata.models'; +import { ItemDataService } from '../../../../../core/data/item-data.service'; +import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service'; + +@listableObjectComponent('PersonSearchResult', ViewMode.ListElement, Context.SubmissionModal) +@Component({ + selector: 'ds-person-search-result-list-submission-element', + styleUrls: ['./person-search-result-list-submission-element.component.scss'], + templateUrl: './person-search-result-list-submission-element.component.html' +}) + +/** + * The component for displaying a list element for an item search result of the type Person + */ +export class PersonSearchResultListSubmissionElementComponent extends SearchResultListElementComponent implements OnInit { + allSuggestions: string[]; + selectedName: string; + alternativeField = 'dc.title.alternative'; + + constructor(protected truncatableService: TruncatableService, + private relationshipService: RelationshipService, + private notificationsService: NotificationsService, + private translateService: TranslateService, + private modalService: NgbModal, + private itemDataService: ItemDataService, + private selectableListService: SelectableListService) { + super(truncatableService); + } + + ngOnInit() { + super.ngOnInit(); + const defaultValue = this.firstMetadataValue('person.familyName') + ', ' + this.firstMetadataValue('person.givenName'); + const alternatives = this.allMetadataValues(this.alternativeField); + this.allSuggestions = [defaultValue, ...alternatives]; + + this.relationshipService.getNameVariant(this.listID, this.dso.uuid) + .pipe(take(1)) + .subscribe((nameVariant: string) => { + this.selectedName = nameVariant || defaultValue; + } + ); + } + + select(value) { + this.selectableListService.isObjectSelected(this.listID, this.object) + .pipe(take(1)) + .subscribe((selected) => { + if (!selected) { + this.selectableListService.selectSingle(this.listID, this.object); + } + }); + this.relationshipService.setNameVariant(this.listID, this.dso.uuid, value); + } + + selectCustom(value) { + if (!this.allSuggestions.includes(value)) { + this.openModal(value) + .then(() => { + + const newName: MetadataValue = new MetadataValue(); + newName.value = value; + + const existingNames: MetadataValue[] = this.dso.metadata[this.alternativeField] || []; + const alternativeNames = { [this.alternativeField]: [...existingNames, newName] }; + const updatedItem = + Object.assign({}, this.dso, { + metadata: { + ...this.dso.metadata, + ...alternativeNames + }, + }); + this.itemDataService.update(updatedItem).pipe(take(1)).subscribe(); + }) + } + this.select(value); + } + + openModal(value): Promise { + const modalRef = this.modalService.open(NameVariantModalComponent, { centered: true }); + const modalComp = modalRef.componentInstance; + modalComp.value = value; + return modalRef.result; + } +} diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.html new file mode 100644 index 0000000000..e177b2b561 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.html @@ -0,0 +1,24 @@ +
+ + + +
\ No newline at end of file diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.scss b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.scss new file mode 100644 index 0000000000..8301e12c5f --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.scss @@ -0,0 +1,18 @@ +form { + z-index: 1; + &:before { + position: absolute; + font-weight: 900; + font-family: "Font Awesome 5 Free"; + content: "\f0d7"; + top: 7px; + right: 0; + height: 20px; + width: 20px; + z-index: -1; + } + + input.suggestion_input { + background: transparent; + } +} \ No newline at end of file diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.ts new file mode 100644 index 0000000000..a1802ce1a7 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-suggestions/person-input-suggestions.component.ts @@ -0,0 +1,48 @@ +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import { InputSuggestionsComponent } from '../../../../../../shared/input-suggestions/input-suggestions.component'; + +@Component({ + selector: 'ds-person-input-suggestions', + styleUrls: ['./person-input-suggestions.component.scss', './../../../../../../shared/input-suggestions/input-suggestions.component.scss'], + templateUrl: './person-input-suggestions.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + // Usage of forwardRef necessary https://github.com/angular/angular.io/issues/1151 + // tslint:disable-next-line:no-forward-ref + useExisting: forwardRef(() => PersonInputSuggestionsComponent), + multi: true + } + ] +}) + +/** + * Component representing a form with a autocomplete functionality + */ +export class PersonInputSuggestionsComponent extends InputSuggestionsComponent implements OnInit { + /** + * The suggestions that should be shown + */ + @Input() suggestions: string[] = []; + + ngOnInit() { + if (this.suggestions.length > 0) { + this.value = this.suggestions[0] + } + } + + onSubmit(data) { + this.value = data; + this.submitSuggestion.emit(data); + } + + onClickSuggestion(data) { + this.value = data; + this.clickSuggestion.emit(data); + this.close(); + this.blockReopen = true; + this.queryInput.nativeElement.focus(); + return false; + } +} diff --git a/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.html b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.html new file mode 100644 index 0000000000..13ae884ccb --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.html @@ -0,0 +1,13 @@ + + + diff --git a/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.scss b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.spec.ts b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.spec.ts new file mode 100644 index 0000000000..b5043ea2d6 --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.spec.ts @@ -0,0 +1,53 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NameVariantModalComponent } from './name-variant-modal.component'; +import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; + +describe('NameVariantModalComponent', () => { + let component: NameVariantModalComponent; + let fixture: ComponentFixture; + let debugElement; + let modal; + + function init() { + modal = jasmine.createSpyObj('modal', ['close', 'dismiss']); + } + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [NameVariantModalComponent], + imports: [NgbModule.forRoot(), TranslateModule.forRoot()], + providers: [{ provide: NgbActiveModal, useValue: modal }] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(NameVariantModalComponent); + component = fixture.componentInstance; + debugElement = fixture.debugElement; + fixture.detectChanges(); + + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('when close button is clicked, dismiss should be called on the modal', () => { + debugElement.query(By.css('button.close')).triggerEventHandler('click', {}); + expect(modal.dismiss).toHaveBeenCalled(); + }); + + it('when confirm button is clicked, close should be called on the modal', () => { + debugElement.query(By.css('button.confirm-button')).triggerEventHandler('click', {}); + expect(modal.close).toHaveBeenCalled(); + }); + + it('when decline button is clicked, dismiss should be called on the modal', () => { + debugElement.query(By.css('button.decline-button')).triggerEventHandler('click', {}); + expect(modal.dismiss).toHaveBeenCalled(); + }); +}); diff --git a/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.ts b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.ts new file mode 100644 index 0000000000..75817d786a --- /dev/null +++ b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +/** + * This component a pop up for when the user selects a custom name variant during submission for a relationship$ + * The user can either choose to decline or accept to save the name variant as a metadata in the entity + */ +@Component({ + selector: 'ds-name-variant-modal', + templateUrl: './name-variant-modal.component.html', + styleUrls: ['./name-variant-modal.component.scss'] +}) +export class NameVariantModalComponent { + @Input() value: string; + + constructor(public modal: NgbActiveModal) { + } +} diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index 4c7c3cd030..b2ba10fb98 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -53,17 +53,18 @@ export class NavbarComponent extends MenuComponent implements OnInit { } as TextMenuItemModel, index: 0 }, - // { - // id: 'browse_global_communities_and_collections', - // parentID: 'browse_global', - // active: false, - // visible: true, - // model: { - // type: MenuItemType.LINK, - // text: 'menu.section.browse_global_communities_and_collections', - // link: '#' - // } as LinkMenuItemModel, - // }, + /* Communities & Collections tree */ + { + id: `browse_global_communities_and_collections`, + parentID: 'browse_global', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: `menu.section.browse_global_communities_and_collections`, + link: `/community-list` + } as LinkMenuItemModel + }, /* Statistics */ { diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html index 6c67937063..09f7e459e4 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.html @@ -1,3 +1,38 @@ +
+
+
+ +
+ +
+
+
+ +
+
+
+ + +
+
+
+
+
+ +
+
+
+
diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts index 1b44970402..42e2c4ccb5 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts @@ -7,10 +7,22 @@ import { DynamicFormService, DynamicInputModel } from '@ng-dynamic-forms/core'; import { FormControl, FormGroup } from '@angular/forms'; import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynamic-form-control.model'; import { Community } from '../../../core/shared/community.model'; -import { ResourceType } from '../../../core/shared/resource-type'; import { ComColFormComponent } from './comcol-form.component'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { hasValue } from '../../empty.util'; +import { VarDirective } from '../../utils/var.directive'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service-stub'; +import { AuthService } from '../../../core/auth/auth.service'; +import { AuthServiceMock } from '../../mocks/mock-auth.service'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { RestRequestMethod } from '../../../core/data/rest-request-method'; +import { ErrorResponse, RestResponse } from '../../../core/cache/response.models'; +import { RequestError } from '../../../core/data/request.models'; +import { RequestService } from '../../../core/data/request.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { By } from '@angular/platform-browser'; describe('ComColFormComponent', () => { let comp: ComColFormComponent; @@ -49,71 +61,264 @@ describe('ComColFormComponent', () => { }) ]; + const logoEndpoint = 'rest/api/logo/endpoint'; + const dsoService = Object.assign({ + getLogoEndpoint: () => observableOf(logoEndpoint), + deleteLogo: () => observableOf({}) + }); + const notificationsService = new NotificationsServiceStub(); + /* tslint:disable:no-empty */ const locationStub = jasmine.createSpyObj('location', ['back']); /* tslint:enable:no-empty */ + const requestServiceStub = jasmine.createSpyObj({ + removeByHrefSubstring: {} + }); + const objectCacheStub = jasmine.createSpyObj({ + remove: {} + }); + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule], - declarations: [ComColFormComponent], + declarations: [ComColFormComponent, VarDirective], providers: [ { provide: Location, useValue: locationStub }, - { provide: DynamicFormService, useValue: formServiceStub } + { provide: DynamicFormService, useValue: formServiceStub }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: AuthService, useValue: new AuthServiceMock() }, + { provide: RequestService, useValue: requestServiceStub }, + { provide: ObjectCacheService, useValue: objectCacheStub } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); })); - beforeEach(() => { - fixture = TestBed.createComponent(ComColFormComponent); - comp = fixture.componentInstance; - comp.formModel = []; - comp.dso = new Community(); - fixture.detectChanges(); - location = (comp as any).location; - }); - - describe('onSubmit', () => { + describe('when the dso doesn\'t contain an ID (newly created)', () => { beforeEach(() => { - spyOn(comp.submitForm, 'emit'); - comp.formModel = formModel; + initComponent(new Community()); }); - it('should emit the new version of the community', () => { - comp.dso = Object.assign( - new Community(), - { - metadata: { - ...titleMD, - ...randomMD - } - } - ); + it('should initialize the uploadFilesOptions with a placeholder url', () => { + expect(comp.uploadFilesOptions.url.length).toBeGreaterThan(0); + }); - comp.onSubmit(); + describe('onSubmit', () => { + beforeEach(() => { + spyOn(comp.submitForm, 'emit'); + comp.formModel = formModel; + }); - expect(comp.submitForm.emit).toHaveBeenCalledWith( - Object.assign( - {}, + it('should emit the new version of the community', () => { + comp.dso = Object.assign( new Community(), { metadata: { - ...newTitleMD, - ...randomMD, - ...abstractMD - }, - type: Community.type - }, - ) - ); - }) - }); + ...titleMD, + ...randomMD + } + } + ); - describe('onCancel', () => { - it('should call the back method on the Location service', () => { + comp.onSubmit(); + + expect(comp.submitForm.emit).toHaveBeenCalledWith( + { + dso: Object.assign( + {}, + new Community(), + { + metadata: { + ...newTitleMD, + ...randomMD, + ...abstractMD + }, + type: Community.type + }, + ), + uploader: {}, + deleteLogo: false + } + ); + }) + }); + + describe('onCancel', () => { + it('should call the back method on the Location service', () => { comp.onCancel(); expect(locationStub.back).toHaveBeenCalled(); + }); + }); + + describe('onCompleteItem', () => { + beforeEach(() => { + spyOn(comp.finish, 'emit'); + comp.onCompleteItem(); + }); + + it('should show a success notification', () => { + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should emit finish', () => { + expect(comp.finish.emit).toHaveBeenCalled(); + }); + + it('should remove the object\'s cache', () => { + expect(requestServiceStub.removeByHrefSubstring).toHaveBeenCalled(); + expect(objectCacheStub.remove).toHaveBeenCalled(); + }); + }); + + describe('onUploadError', () => { + beforeEach(() => { + spyOn(comp.finish, 'emit'); + comp.onUploadError(); + }); + + it('should show an error notification', () => { + expect(notificationsService.error).toHaveBeenCalled(); + }); + + it('should emit finish', () => { + expect(comp.finish.emit).toHaveBeenCalled(); + }); }); }); + + describe('when the dso contains an ID (being edited)', () => { + describe('and the dso doesn\'t contain a logo', () => { + beforeEach(() => { + initComponent(Object.assign(new Community(), { + id: 'community-id', + logo: observableOf(new RemoteData(false, false, true, null, undefined)) + })); + }); + + it('should initialize the uploadFilesOptions with the logo\'s endpoint url', () => { + expect(comp.uploadFilesOptions.url).toEqual(logoEndpoint); + }); + + it('should initialize the uploadFilesOptions with a POST method', () => { + expect(comp.uploadFilesOptions.method).toEqual(RestRequestMethod.POST); + }); + }); + + describe('and the dso contains a logo', () => { + beforeEach(() => { + initComponent(Object.assign(new Community(), { + id: 'community-id', + logo: observableOf(new RemoteData(false, false, true, null, {})) + })); + }); + + it('should initialize the uploadFilesOptions with the logo\'s endpoint url', () => { + expect(comp.uploadFilesOptions.url).toEqual(logoEndpoint); + }); + + it('should initialize the uploadFilesOptions with a PUT method', () => { + expect(comp.uploadFilesOptions.method).toEqual(RestRequestMethod.PUT); + }); + + describe('submit with logo marked for deletion', () => { + beforeEach(() => { + comp.markLogoForDeletion = true; + }); + + describe('when dsoService.deleteLogo returns a successful response', () => { + const response = new RestResponse(true, 200, 'OK'); + + beforeEach(() => { + spyOn(dsoService, 'deleteLogo').and.returnValue(observableOf(response)); + comp.onSubmit(); + }); + + it('should display a success notification', () => { + expect(notificationsService.success).toHaveBeenCalled(); + }); + }); + + describe('when dsoService.deleteLogo returns an error response', () => { + const response = new ErrorResponse(new RequestError('errorMessage')); + + beforeEach(() => { + spyOn(dsoService, 'deleteLogo').and.returnValue(observableOf(response)); + comp.onSubmit(); + }); + + it('should display an error notification', () => { + expect(notificationsService.error).toHaveBeenCalled(); + }); + }); + }); + + describe('deleteLogo', () => { + beforeEach(() => { + comp.deleteLogo(); + fixture.detectChanges(); + }); + + it('should set markLogoForDeletion to true', () => { + expect(comp.markLogoForDeletion).toEqual(true); + }); + + it('should mark the logo section with a danger alert', () => { + const logoSection = fixture.debugElement.query(By.css('#logo-section.alert-danger')); + expect(logoSection).toBeTruthy(); + }); + + it('should hide the delete button', () => { + const button = fixture.debugElement.query(By.css('#logo-section .btn-danger')); + expect(button).not.toBeTruthy(); + }); + + it('should show the undo button', () => { + const button = fixture.debugElement.query(By.css('#logo-section .btn-warning')); + expect(button).toBeTruthy(); + }); + }); + + describe('undoDeleteLogo', () => { + beforeEach(() => { + comp.markLogoForDeletion = true; + comp.undoDeleteLogo(); + fixture.detectChanges(); + }); + + it('should set markLogoForDeletion to false', () => { + expect(comp.markLogoForDeletion).toEqual(false); + }); + + it('should disable the danger alert on the logo section', () => { + const logoSection = fixture.debugElement.query(By.css('#logo-section.alert-danger')); + expect(logoSection).not.toBeTruthy(); + }); + + it('should show the delete button', () => { + const button = fixture.debugElement.query(By.css('#logo-section .btn-danger')); + expect(button).toBeTruthy(); + }); + + it('should hide the undo button', () => { + const button = fixture.debugElement.query(By.css('#logo-section .btn-warning')); + expect(button).not.toBeTruthy(); + }); + }); + }); + }); + + function initComponent(dso: Community) { + fixture = TestBed.createComponent(ComColFormComponent); + comp = fixture.componentInstance; + comp.formModel = []; + comp.dso = dso; + (comp as any).type = Community.type; + comp.uploaderComponent = Object.assign({ + uploader: {} + }); + (comp as any).dsoService = dsoService; + fixture.detectChanges(); + location = (comp as any).location; + } }); diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts index 8d1d5c1dca..4db744e7d5 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts @@ -1,17 +1,30 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; import { Location } from '@angular/common'; -import { - DynamicFormService, - DynamicInputModel -} from '@ng-dynamic-forms/core'; +import { DynamicFormService, DynamicInputModel } from '@ng-dynamic-forms/core'; import { FormGroup } from '@angular/forms'; import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynamic-form-control.model'; import { TranslateService } from '@ngx-translate/core'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { MetadataMap, MetadataValue } from '../../../core/shared/metadata.models'; import { ResourceType } from '../../../core/shared/resource-type'; -import { isNotEmpty } from '../../empty.util'; +import { hasValue, isNotEmpty } from '../../empty.util'; +import { UploaderOptions } from '../../uploader/uploader-options.model'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { ComColDataService } from '../../../core/data/comcol-data.service'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { AuthService } from '../../../core/auth/auth.service'; import { Community } from '../../../core/shared/community.model'; +import { Collection } from '../../../core/shared/collection.model'; +import { UploaderComponent } from '../../uploader/uploader.component'; +import { FileUploader } from 'ng2-file-upload'; +import { ErrorResponse, RestResponse } from '../../../core/cache/response.models'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { RestRequestMethod } from '../../../core/data/rest-request-method'; +import { RequestService } from '../../../core/data/request.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; /** * A form for creating and editing Communities or Collections @@ -21,7 +34,13 @@ import { Community } from '../../../core/shared/community.model'; styleUrls: ['./comcol-form.component.scss'], templateUrl: './comcol-form.component.html' }) -export class ComColFormComponent implements OnInit { +export class ComColFormComponent implements OnInit, OnDestroy { + + /** + * The logo uploader component + */ + @ViewChild(UploaderComponent) uploaderComponent: UploaderComponent; + /** * DSpaceObject that the form represents */ @@ -30,7 +49,7 @@ export class ComColFormComponent implements OnInit { /** * Type of DSpaceObject that the form represents */ - protected type: ResourceType; + type: ResourceType; /** * @type {string} Key prefix used to generate form labels @@ -53,14 +72,56 @@ export class ComColFormComponent implements OnInit { formGroup: FormGroup; /** - * Emits DSO when the form is submitted - * @type {EventEmitter} + * The uploader configuration options + * @type {UploaderOptions} */ - @Output() submitForm: EventEmitter = new EventEmitter(); + uploadFilesOptions: UploaderOptions = Object.assign(new UploaderOptions(), { + autoUpload: false + }); - public constructor(private location: Location, - private formService: DynamicFormService, - private translate: TranslateService) { + /** + * Emits DSO and Uploader when the form is submitted + */ + @Output() submitForm: EventEmitter<{ + dso: T, + uploader: FileUploader, + deleteLogo: boolean + }> = new EventEmitter(); + + /** + * Fires an event when the logo has finished uploading (with or without errors) or was removed + */ + @Output() finish: EventEmitter = new EventEmitter(); + + /** + * Observable keeping track whether or not the uploader has finished initializing + * Used to start rendering the uploader component + */ + initializedUploaderOptions = new BehaviorSubject(false); + + /** + * Is the logo marked to be deleted? + */ + markLogoForDeletion = false; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * The service used to fetch from or send data to + */ + protected dsoService: ComColDataService; + + public constructor(protected location: Location, + protected formService: DynamicFormService, + protected translate: TranslateService, + protected notificationsService: NotificationsService, + protected authService: AuthService, + protected requestService: RequestService, + protected objectCache: ObjectCacheService) { } ngOnInit(): void { @@ -76,12 +137,55 @@ export class ComColFormComponent implements OnInit { .subscribe(() => { this.updateFieldTranslations(); }); + + if (hasValue(this.dso.id)) { + this.subs.push( + observableCombineLatest( + this.dsoService.getLogoEndpoint(this.dso.id), + (this.dso as any).logo + ).subscribe(([href, logoRD]: [string, RemoteData]) => { + this.uploadFilesOptions.url = href; + this.uploadFilesOptions.authToken = this.authService.buildAuthHeader(); + // If the object already contains a logo, send out a PUT request instead of POST for setting a new logo + if (hasValue(logoRD.payload)) { + this.uploadFilesOptions.method = RestRequestMethod.PUT; + } + this.initializedUploaderOptions.next(true); + }) + ); + } else { + // Set a placeholder URL to not break the uploader component. This will be replaced once the object is created. + this.uploadFilesOptions.url = 'placeholder'; + this.uploadFilesOptions.authToken = this.authService.buildAuthHeader(); + this.initializedUploaderOptions.next(true); + } } /** * Checks which new fields were added and sends the updated version of the DSO to the parent component */ onSubmit() { + if (this.markLogoForDeletion && hasValue(this.dso.id)) { + this.dsoService.deleteLogo(this.dso).subscribe((response: RestResponse) => { + if (response.isSuccessful) { + this.notificationsService.success( + this.translate.get(this.type.value + '.edit.logo.notifications.delete.success.title'), + this.translate.get(this.type.value + '.edit.logo.notifications.delete.success.content') + ); + } else { + const errorResponse = response as ErrorResponse; + this.notificationsService.error( + this.translate.get(this.type.value + '.edit.logo.notifications.delete.error.title'), + errorResponse.errorMessage + ); + } + (this.dso as any).logo = undefined; + this.uploadFilesOptions.method = RestRequestMethod.POST; + this.refreshCache(); + this.finish.emit(); + }); + } + const formMetadata = new Object() as MetadataMap; this.formModel.forEach((fieldModel: DynamicInputModel) => { const value: MetadataValue = { @@ -102,7 +206,11 @@ export class ComColFormComponent implements OnInit { }, type: Community.type }); - this.submitForm.emit(updatedDSO); + this.submitForm.emit({ + dso: updatedDSO, + uploader: hasValue(this.uploaderComponent) ? this.uploaderComponent.uploader : undefined, + deleteLogo: this.markLogoForDeletion + }); } /** @@ -122,7 +230,59 @@ export class ComColFormComponent implements OnInit { ); } + /** + * Mark the logo to be deleted + * Send out a delete request to remove the logo from the community/collection and display notifications + */ + deleteLogo() { + this.markLogoForDeletion = true; + } + + /** + * Undo marking the logo to be deleted + */ + undoDeleteLogo() { + this.markLogoForDeletion = false; + } + + /** + * Refresh the object's cache to ensure the latest version + */ + private refreshCache() { + this.requestService.removeByHrefSubstring(this.dso.self); + this.objectCache.remove(this.dso.self); + } + + /** + * The request was successful, display a success notification + */ + public onCompleteItem() { + this.refreshCache(); + this.notificationsService.success(null, this.translate.get(this.type.value + '.edit.logo.notifications.add.success')); + this.finish.emit(); + } + + /** + * The request was unsuccessful, display an error notification + */ + public onUploadError() { + this.notificationsService.error(null, this.translate.get(this.type.value + '.edit.logo.notifications.add.error')); + this.finish.emit(); + } + + /** + * Cancel the form and return to the previous page + */ onCancel() { this.location.back(); } + + /** + * Unsubscribe from open subscriptions + */ + ngOnDestroy(): void { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } } diff --git a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts index 6ad2e5b5e1..717979891f 100644 --- a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts +++ b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts @@ -11,11 +11,13 @@ import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { CreateComColPageComponent } from './create-comcol-page.component'; -import { DataService } from '../../../core/data/data.service'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../testing/utils'; +import { ComColDataService } from '../../../core/data/comcol-data.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service-stub'; describe('CreateComColPageComponent', () => { let comp: CreateComColPageComponent; @@ -31,6 +33,8 @@ describe('CreateComColPageComponent', () => { let routeServiceStub; let routerStub; + const logoEndpoint = 'rest/api/logo/endpoint'; + function initializeVars() { community = Object.assign(new Community(), { uuid: 'a20da287-e174-466a-9926-f66b9300d347', @@ -56,8 +60,8 @@ describe('CreateComColPageComponent', () => { value: community.name }] })), - create: (com, uuid?) => createSuccessfulRemoteDataObject$(newCommunity) - + create: (com, uuid?) => createSuccessfulRemoteDataObject$(newCommunity), + getLogoEndpoint: () => observableOf(logoEndpoint) }; routeServiceStub = { @@ -74,10 +78,11 @@ describe('CreateComColPageComponent', () => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], providers: [ - { provide: DataService, useValue: communityDataServiceStub }, + { provide: ComColDataService, useValue: communityDataServiceStub }, { provide: CommunityDataService, useValue: communityDataServiceStub }, { provide: RouteService, useValue: routeServiceStub }, { provide: Router, useValue: routerStub }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -86,6 +91,7 @@ describe('CreateComColPageComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(CreateComColPageComponent); comp = fixture.componentInstance; + (comp as any).type = Community.type; fixture.detectChanges(); dsoDataService = (comp as any).dsoDataService; communityDataService = (comp as any).communityDataService; @@ -95,27 +101,86 @@ describe('CreateComColPageComponent', () => { describe('onSubmit', () => { let data; - beforeEach(() => { - data = Object.assign(new Community(), { - metadata: [{ - key: 'dc.title', - value: 'test' - }] + + describe('with an empty queue in the uploader', () => { + beforeEach(() => { + data = { + dso: Object.assign(new Community(), { + metadata: [{ + key: 'dc.title', + value: 'test' + }] + }), + uploader: { + options: { + url: '' + }, + queue: [], + /* tslint:disable:no-empty */ + uploadAll: () => {} + /* tslint:enable:no-empty */ + } + }; + }); + + it('should navigate when successful', () => { + spyOn(router, 'navigate'); + comp.onSubmit(data); + fixture.detectChanges(); + expect(router.navigate).toHaveBeenCalled(); + }); + + it('should not navigate on failure', () => { + spyOn(router, 'navigate'); + spyOn(dsoDataService, 'create').and.returnValue(createFailedRemoteDataObject$(newCommunity)); + comp.onSubmit(data); + fixture.detectChanges(); + expect(router.navigate).not.toHaveBeenCalled(); }); }); - it('should navigate when successful', () => { - spyOn(router, 'navigate'); - comp.onSubmit(data); - fixture.detectChanges(); - expect(router.navigate).toHaveBeenCalled(); - }); - it('should not navigate on failure', () => { - spyOn(router, 'navigate'); - spyOn(dsoDataService, 'create').and.returnValue(createFailedRemoteDataObject$(newCommunity)); - comp.onSubmit(data); - fixture.detectChanges(); - expect(router.navigate).not.toHaveBeenCalled(); + describe('with at least one item in the uploader\'s queue', () => { + beforeEach(() => { + data = { + dso: Object.assign(new Community(), { + metadata: [{ + key: 'dc.title', + value: 'test' + }] + }), + uploader: { + options: { + url: '' + }, + queue: [ + {} + ], + /* tslint:disable:no-empty */ + uploadAll: () => {} + /* tslint:enable:no-empty */ + } + }; + }); + + it('should not navigate', () => { + spyOn(router, 'navigate'); + comp.onSubmit(data); + fixture.detectChanges(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should set the uploader\'s url to the logo\'s endpoint', () => { + comp.onSubmit(data); + fixture.detectChanges(); + expect(data.uploader.options.url).toEqual(logoEndpoint); + }); + + it('should call the uploader\'s uploadAll', () => { + spyOn(data.uploader, 'uploadAll'); + comp.onSubmit(data); + fixture.detectChanges(); + expect(data.uploader.uploadAll).toHaveBeenCalled(); + }); }); }); }); diff --git a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts index e07f2a5a0a..7b23c59498 100644 --- a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts @@ -3,13 +3,17 @@ import { Community } from '../../../core/shared/community.model'; import { CommunityDataService } from '../../../core/data/community-data.service'; import { Observable } from 'rxjs'; import { RouteService } from '../../../core/services/route.service'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { RemoteData } from '../../../core/data/remote-data'; -import { isNotEmpty, isNotUndefined } from '../../empty.util'; +import { hasValue, isNotEmpty, isNotUndefined } from '../../empty.util'; import { take } from 'rxjs/operators'; import { getSucceededRemoteData } from '../../../core/shared/operators'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { DataService } from '../../../core/data/data.service'; +import { ComColDataService } from '../../../core/data/comcol-data.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { ResourceType } from '../../../core/shared/resource-type'; /** * Component representing the create page for communities and collections @@ -34,11 +38,23 @@ export class CreateComColPageComponent implements */ public parentRD$: Observable>; + /** + * The UUID of the newly created object + */ + private newUUID: string; + + /** + * The type of the dso + */ + protected type: ResourceType; + public constructor( - protected dsoDataService: DataService, + protected dsoDataService: ComColDataService, protected parentDataService: CommunityDataService, protected routeService: RouteService, - protected router: Router + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService ) { } @@ -53,20 +69,40 @@ export class CreateComColPageComponent implements } /** - * @param {TDomain} dso The updated version of the DSO * Creates a new DSO based on the submitted user data and navigates to the new object's home page + * @param event The event returned by the community/collection form. Contains the new dso and logo uploader */ - onSubmit(dso: TDomain) { + onSubmit(event) { + const dso = event.dso; + const uploader = event.uploader; + this.parentUUID$.pipe(take(1)).subscribe((uuid: string) => { this.dsoDataService.create(dso, uuid) .pipe(getSucceededRemoteData()) .subscribe((dsoRD: RemoteData) => { if (isNotUndefined(dsoRD)) { - const newUUID = dsoRD.payload.uuid; - this.router.navigate([this.frontendURL + newUUID]); + this.newUUID = dsoRD.payload.uuid; + if (uploader.queue.length > 0) { + this.dsoDataService.getLogoEndpoint(this.newUUID).pipe(take(1)).subscribe((href: string) => { + uploader.options.url = href; + uploader.uploadAll(); + }); + } else { + this.navigateToNewPage(); + } + this.notificationsService.success(null, this.translate.get(this.type.value + '.create.notifications.success')); } }); }); } + /** + * Navigate to the page of the newly created object + */ + navigateToNewPage() { + if (hasValue(this.newUUID)) { + this.router.navigate([this.frontendURL + this.newUUID]); + } + } + } diff --git a/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts b/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts new file mode 100644 index 0000000000..5711aa4e70 --- /dev/null +++ b/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts @@ -0,0 +1,189 @@ +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommunityDataService } from '../../../../core/data/community-data.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Community } from '../../../../core/shared/community.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { TranslateModule } from '@ngx-translate/core'; +import { SharedModule } from '../../../shared.module'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { DataService } from '../../../../core/data/data.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComcolMetadataComponent } from './comcol-metadata.component'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../testing/utils'; +import { ComColDataService } from '../../../../core/data/comcol-data.service'; +import { NotificationsServiceStub } from '../../../testing/notifications-service-stub'; +import { NotificationsService } from '../../../notifications/notifications.service'; + +describe('ComColMetadataComponent', () => { + let comp: ComcolMetadataComponent; + let fixture: ComponentFixture>; + let dsoDataService: CommunityDataService; + let router: Router; + + let community; + let newCommunity; + let communityDataServiceStub; + let routerStub; + let routeStub; + + const logoEndpoint = 'rest/api/logo/endpoint'; + + function initializeVars() { + community = Object.assign(new Community(), { + uuid: 'a20da287-e174-466a-9926-f66b9300d347', + metadata: [{ + key: 'dc.title', + value: 'test community' + }] + }); + + newCommunity = Object.assign(new Community(), { + uuid: '1ff59938-a69a-4e62-b9a4-718569c55d48', + metadata: [{ + key: 'dc.title', + value: 'new community' + }] + }); + + communityDataServiceStub = { + update: (com, uuid?) => createSuccessfulRemoteDataObject$(newCommunity), + getLogoEndpoint: () => observableOf(logoEndpoint) + }; + + routerStub = { + navigate: (commands) => commands + }; + + routeStub = { + parent: { + data: observableOf({ + dso: new RemoteData(false, false, true, null, community) + }) + } + }; + + } + + beforeEach(async(() => { + initializeVars(); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], + providers: [ + { provide: ComColDataService, useValue: communityDataServiceStub }, + { provide: Router, useValue: routerStub }, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ComcolMetadataComponent); + comp = fixture.componentInstance; + (comp as any).type = Community.type; + fixture.detectChanges(); + dsoDataService = (comp as any).dsoDataService; + router = (comp as any).router; + }); + + describe('onSubmit', () => { + let data; + + describe('with an empty queue in the uploader', () => { + beforeEach(() => { + data = { + dso: Object.assign(new Community(), { + metadata: [{ + key: 'dc.title', + value: 'test' + }] + }), + uploader: { + options: { + url: '' + }, + queue: [], + /* tslint:disable:no-empty */ + uploadAll: () => {} + /* tslint:enable:no-empty */ + } + } + }); + + it('should navigate when successful', () => { + spyOn(router, 'navigate'); + comp.onSubmit(data); + fixture.detectChanges(); + expect(router.navigate).toHaveBeenCalled(); + }); + + it('should not navigate on failure', () => { + spyOn(router, 'navigate'); + spyOn(dsoDataService, 'update').and.returnValue(createFailedRemoteDataObject$(newCommunity)); + comp.onSubmit(data); + fixture.detectChanges(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + }); + + describe('with at least one item in the uploader\'s queue', () => { + beforeEach(() => { + data = { + dso: Object.assign(new Community(), { + metadata: [{ + key: 'dc.title', + value: 'test' + }] + }), + uploader: { + options: { + url: '' + }, + queue: [ + {} + ], + /* tslint:disable:no-empty */ + uploadAll: () => {} + /* tslint:enable:no-empty */ + } + } + }); + + it('should not navigate', () => { + spyOn(router, 'navigate'); + comp.onSubmit(data); + fixture.detectChanges(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should set the uploader\'s url to the logo\'s endpoint', () => { + comp.onSubmit(data); + fixture.detectChanges(); + expect(data.uploader.options.url).toEqual(logoEndpoint); + }); + + it('should call the uploader\'s uploadAll', () => { + spyOn(data.uploader, 'uploadAll'); + comp.onSubmit(data); + fixture.detectChanges(); + expect(data.uploader.uploadAll).toHaveBeenCalled(); + }); + }); + }); + + describe('navigateToHomePage', () => { + beforeEach(() => { + spyOn(router, 'navigate'); + comp.navigateToHomePage(); + }); + + it('should navigate', () => { + expect(router.navigate).toHaveBeenCalled(); + }); + }); + +}); diff --git a/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.ts b/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.ts new file mode 100644 index 0000000000..1031fead10 --- /dev/null +++ b/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.ts @@ -0,0 +1,85 @@ +import { Component, OnInit } from '@angular/core'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { ActivatedRoute, Router } from '@angular/router'; +import { first, map, take } from 'rxjs/operators'; +import { getSucceededRemoteData } from '../../../../core/shared/operators'; +import { hasValue, isNotUndefined } from '../../../empty.util'; +import { DataService } from '../../../../core/data/data.service'; +import { ResourceType } from '../../../../core/shared/resource-type'; +import { ComColDataService } from '../../../../core/data/comcol-data.service'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'ds-comcol-metadata', + template: '' +}) +export class ComcolMetadataComponent implements OnInit { + /** + * Frontend endpoint for this type of DSO + */ + protected frontendURL: string; + /** + * The initial DSO object + */ + public dsoRD$: Observable>; + + /** + * The type of the dso + */ + protected type: ResourceType; + + public constructor( + protected dsoDataService: ComColDataService, + protected router: Router, + protected route: ActivatedRoute, + protected notificationsService: NotificationsService, + protected translate: TranslateService + ) { + } + + ngOnInit(): void { + this.dsoRD$ = this.route.parent.data.pipe(first(), map((data) => data.dso)); + } + + /** + * Updates an existing DSO based on the submitted user data and navigates to the edited object's home page + * @param event The event returned by the community/collection form. Contains the new dso and logo uploader + */ + onSubmit(event) { + const dso = event.dso; + const uploader = event.uploader; + const deleteLogo = event.deleteLogo; + + this.dsoDataService.update(dso) + .pipe(getSucceededRemoteData()) + .subscribe((dsoRD: RemoteData) => { + if (isNotUndefined(dsoRD)) { + const newUUID = dsoRD.payload.uuid; + if (hasValue(uploader) && uploader.queue.length > 0) { + this.dsoDataService.getLogoEndpoint(newUUID).pipe(take(1)).subscribe((href: string) => { + uploader.options.url = href; + uploader.uploadAll(); + }); + } else if (!deleteLogo) { + this.router.navigate([this.frontendURL + newUUID]); + } + this.notificationsService.success(null, this.translate.get(this.type.value + '.edit.notifications.success')); + } + }); + } + + /** + * Navigate to the home page of the object + */ + navigateToHomePage() { + this.dsoRD$.pipe( + getSucceededRemoteData(), + take(1) + ).subscribe((dsoRD: RemoteData) => { + this.router.navigate([this.frontendURL + dsoRD.payload.id]); + }); + } +} diff --git a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.html b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.html new file mode 100644 index 0000000000..aa6290ea9f --- /dev/null +++ b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.html @@ -0,0 +1,24 @@ +
+
+
+

{{ type + '.edit.head' | translate }}

+ +
+
+
diff --git a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.spec.ts b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.spec.ts index 03f751599f..d1b87db7ae 100644 --- a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.spec.ts +++ b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.spec.ts @@ -1,5 +1,4 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { CommunityDataService } from '../../../core/data/community-data.service'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; @@ -10,21 +9,13 @@ import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { EditComColPageComponent } from './edit-comcol-page.component'; -import { DataService } from '../../../core/data/data.service'; -import { - createFailedRemoteDataObject$, - createSuccessfulRemoteDataObject$ -} from '../../testing/utils'; describe('EditComColPageComponent', () => { let comp: EditComColPageComponent; let fixture: ComponentFixture>; - let dsoDataService: CommunityDataService; let router: Router; let community; - let newCommunity; - let communityDataServiceStub; let routerStub; let routeStub; @@ -37,25 +28,33 @@ describe('EditComColPageComponent', () => { }] }); - newCommunity = Object.assign(new Community(), { - uuid: '1ff59938-a69a-4e62-b9a4-718569c55d48', - metadata: [{ - key: 'dc.title', - value: 'new community' - }] - }); - - communityDataServiceStub = { - update: (com, uuid?) => createSuccessfulRemoteDataObject$(newCommunity) - - }; - routerStub = { - navigate: (commands) => commands + navigate: (commands) => commands, + events: observableOf({}), + url: 'mockUrl' }; routeStub = { - data: observableOf(community) + data: observableOf({ + dso: community + }), + routeConfig: { + children: [ + { + path: 'mockUrl', + data: { + hideReturnButton: false + } + } + ] + }, + snapshot: { + firstChild: { + routeConfig: { + path: 'mockUrl' + } + } + } }; } @@ -65,7 +64,6 @@ describe('EditComColPageComponent', () => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule], providers: [ - { provide: DataService, useValue: communityDataServiceStub }, { provide: Router, useValue: routerStub }, { provide: ActivatedRoute, useValue: routeStub }, ], @@ -77,33 +75,16 @@ describe('EditComColPageComponent', () => { fixture = TestBed.createComponent(EditComColPageComponent); comp = fixture.componentInstance; fixture.detectChanges(); - dsoDataService = (comp as any).dsoDataService; router = (comp as any).router; }); - describe('onSubmit', () => { - let data; + describe('getPageUrl', () => { + let url; beforeEach(() => { - data = Object.assign(new Community(), { - metadata: [{ - key: 'dc.title', - value: 'test' - }] - }); + url = comp.getPageUrl(community); }); - it('should navigate when successful', () => { - spyOn(router, 'navigate'); - comp.onSubmit(data); - fixture.detectChanges(); - expect(router.navigate).toHaveBeenCalled(); - }); - - it('should not navigate on failure', () => { - spyOn(router, 'navigate'); - spyOn(dsoDataService, 'update').and.returnValue(createFailedRemoteDataObject$(newCommunity)); - comp.onSubmit(data); - fixture.detectChanges(); - expect(router.navigate).not.toHaveBeenCalled(); + it('should return the current url as a fallback', () => { + expect(url).toEqual(routerStub.url); }); }); }); diff --git a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts index 24181b5e61..0f9d4c55b4 100644 --- a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { ActivatedRoute, Router } from '@angular/router'; import { RemoteData } from '../../../core/data/remote-data'; -import { isNotUndefined } from '../../empty.util'; +import { isNotEmpty, isNotUndefined } from '../../empty.util'; import { first, map } from 'rxjs/operators'; import { getSucceededRemoteData } from '../../../core/shared/operators'; import { DataService } from '../../../core/data/data.service'; @@ -17,37 +17,54 @@ import { DSpaceObject } from '../../../core/shared/dspace-object.model'; }) export class EditComColPageComponent implements OnInit { /** - * Frontend endpoint for this type of DSO + * The type of DSpaceObject (used to create i18n messages) */ - protected frontendURL: string; + public type: string; + /** - * The initial DSO object + * The current page outlet string + */ + public currentPage: string; + + /** + * All possible page outlet strings + */ + public pages: string[]; + + /** + * The DSO to render the edit page for */ public dsoRD$: Observable>; + /** + * Hide the default return button? + */ + public hideReturnButton: boolean; + public constructor( - protected dsoDataService: DataService, protected router: Router, protected route: ActivatedRoute ) { + this.router.events.subscribe(() => { + this.currentPage = this.route.snapshot.firstChild.routeConfig.path; + this.hideReturnButton = this.route.routeConfig.children + .find((child: any) => child.path === this.currentPage).data.hideReturnButton; + }); } ngOnInit(): void { + this.pages = this.route.routeConfig.children + .map((child: any) => child.path) + .filter((path: string) => isNotEmpty(path)); // ignore reroutes this.dsoRD$ = this.route.data.pipe(first(), map((data) => data.dso)); } /** - * @param {TDomain} dso The updated version of the DSO - * Updates an existing DSO based on the submitted user data and navigates to the edited object's home page + * Get the dso's page url + * This method is expected to be overridden in the edit community/collection page components + * @param dso The DSpaceObject for which the url is requested */ - onSubmit(dso: TDomain) { - this.dsoDataService.update(dso) - .pipe(getSucceededRemoteData()) - .subscribe((dsoRD: RemoteData) => { - if (isNotUndefined(dsoRD)) { - const newUUID = dsoRD.payload.uuid; - this.router.navigate([this.frontendURL + newUUID]); - } - }); + getPageUrl(dso: TDomain): string { + return this.router.url; } } diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts index 234f13f4b1..2de59d614b 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts @@ -2,14 +2,14 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { DSOSelectorComponent } from './dso-selector.component'; -import { SearchService } from '../../../+search-page/search-service/search.service'; +import { SearchService } from '../../../core/shared/search/search.service'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; -import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model'; import { ItemSearchResult } from '../../object-collection/shared/item-search-result.model'; import { Item } from '../../../core/shared/item.model'; import { PaginatedList } from '../../../core/data/paginated-list'; import { MetadataValue } from '../../../core/shared/metadata.models'; import { createSuccessfulRemoteDataObject$ } from '../../testing/utils'; +import { PaginatedSearchOptions } from '../../search/paginated-search-options.model'; describe('DSOSelectorComponent', () => { let component: DSOSelectorComponent; 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 af26f3f04f..3c9d399f8b 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 @@ -12,12 +12,12 @@ import { FormControl } from '@angular/forms'; import { Observable } from 'rxjs'; import { debounceTime, startWith, switchMap } from 'rxjs/operators'; -import { SearchService } from '../../../+search-page/search-service/search.service'; -import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { PaginatedSearchOptions } from '../../search/paginated-search-options.model'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; -import { SearchResult } from '../../../+search-page/search-result.model'; +import { SearchResult } from '../../search/search-result.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { ViewMode } from '../../../core/shared/view-mode.model'; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index 52a924604f..144848b478 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -9,37 +9,60 @@ -
-
+
+
- + - + -
- {{ message | translate:model.validators }} -
+
+ {{ message | translate:model.validators }} +
+
+ +
+ +
+ +
+ +
-
- -
-
- + +
+
    +
  • + + + + +
  • +
+
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts index 13a9ba4e85..91c1dbc085 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts @@ -1,5 +1,5 @@ import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; -import { CUSTOM_ELEMENTS_SCHEMA, DebugElement, SimpleChange } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA, DebugElement, NgZone, SimpleChange } from '@angular/core'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; import { By } from '@angular/platform-browser'; @@ -39,10 +39,7 @@ import { } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; -import { - DsDynamicFormControlContainerComponent, - dsDynamicFormControlMapFn -} from './ds-dynamic-form-control-container.component'; +import { DsDynamicFormControlContainerComponent, dsDynamicFormControlMapFn } from './ds-dynamic-form-control-container.component'; import { SharedModule } from '../../../shared.module'; import { DynamicDsDatePickerModel } from './models/date-picker/date-picker.model'; import { DynamicRelationGroupModel } from './models/relation-group/dynamic-relation-group.model'; @@ -65,6 +62,15 @@ import { DsDynamicFormArrayComponent } from './models/array-group/dynamic-form-a import { DsDynamicFormGroupComponent } from './models/form-group/dynamic-form-group.component'; import { DsDynamicRelationGroupComponent } from './models/relation-group/dynamic-relation-group.components'; import { DsDatePickerInlineComponent } from './models/date-picker-inline/dynamic-date-picker-inline.component'; +import { RelationshipService } from '../../../../core/data/relationship.service'; +import { SelectableListService } from '../../../object-list/selectable-list/selectable-list.service'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Store } from '@ngrx/store'; +import { SubmissionObjectDataService } from '../../../../core/submission/submission-object-data.service'; +import { Item } from '../../../../core/shared/item.model'; +import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject } from '../../../testing/utils'; describe('DsDynamicFormControlContainerComponent test suite', () => { @@ -95,12 +101,15 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { new DynamicSwitchModel({ id: 'switch' }), new DynamicTextAreaModel({ id: 'textarea' }), new DynamicTimePickerModel({ id: 'timepicker' }), - new DynamicTypeaheadModel({ id: 'typeahead' }), + new DynamicTypeaheadModel({ id: 'typeahead', metadataFields: [], repeatable: false, submissionId: '1234' }), new DynamicScrollableDropdownModel({ id: 'scrollableDropdown', - authorityOptions: authorityOptions + authorityOptions: authorityOptions, + metadataFields: [], + repeatable: false, + submissionId: '1234' }), - new DynamicTagModel({ id: 'tag' }), + new DynamicTagModel({ id: 'tag', metadataFields: [], repeatable: false, submissionId: '1234' }), new DynamicListCheckboxGroupModel({ id: 'checkboxList', authorityOptions: authorityOptions, @@ -112,18 +121,21 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { repeatable: false }), new DynamicRelationGroupModel({ + submissionId: '1234', id: 'relationGroup', formConfiguration: [], mandatoryField: '', name: 'relationGroup', relationFields: [], scopeUUID: '', - submissionScope: '' + submissionScope: '', + repeatable: false, + metadataFields: [] }), new DynamicDsDatePickerModel({ id: 'datepicker' }), - new DynamicLookupModel({ id: 'lookup' }), - new DynamicLookupNameModel({ id: 'lookupName' }), - new DynamicQualdropModel({ id: 'combobox', readOnly: false }) + new DynamicLookupModel({ id: 'lookup', metadataFields: [], repeatable: false, submissionId: '1234' }), + new DynamicLookupNameModel({ id: 'lookupName', metadataFields: [], repeatable: false, submissionId: '1234' }), + new DynamicQualdropModel({ id: 'combobox', readOnly: false, required: false }) ]; const testModel = formModel[8]; let formGroup: FormGroup; @@ -131,7 +143,9 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { let component: DsDynamicFormControlContainerComponent; let debugElement: DebugElement; let testElement: DebugElement; - + const testItem: Item = new Item(); + const testWSI: WorkspaceItem = new WorkspaceItem(); + testWSI.item = observableOf(createSuccessfulRemoteDataObject(testItem)); beforeEach(async(() => { TestBed.overrideModule(BrowserDynamicTestingModule, { @@ -150,14 +164,34 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { DynamicFormsCoreModule.forRoot(), SharedModule, TranslateModule.forRoot(), - TextMaskModule + TextMaskModule, + ], + providers: [ + DsDynamicFormControlContainerComponent, + DynamicFormService, + { provide: RelationshipService, useValue: {} }, + { provide: SelectableListService, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + { provide: Store, useValue: {} }, + { provide: RelationshipService, useValue: {} }, + { provide: SelectableListService, useValue: {} }, + { + provide: SubmissionObjectDataService, + useValue: { + findById: () => observableOf(createSuccessfulRemoteDataObject(testWSI)) + } + }, + { provide: NgZone, useValue: new NgZone({}) } ], - providers: [DsDynamicFormControlContainerComponent, DynamicFormService], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents().then(() => { fixture = TestBed.createComponent(DsDynamicFormControlContainerComponent); + const ngZone = TestBed.get(NgZone); + + // tslint:disable-next-line:ban-types + spyOn(ngZone, 'runOutsideAngular').and.callFake((fn: Function) => fn()); component = fixture.componentInstance; debugElement = fixture.debugElement; }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index 455c1075ef..c85ef11e5a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -5,7 +5,9 @@ import { ContentChildren, EventEmitter, Input, - OnChanges, + NgZone, + OnChanges, OnDestroy, + OnInit, Output, QueryList, SimpleChanges, @@ -47,6 +49,7 @@ import { DynamicNGBootstrapTimePickerComponent } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; +import { MetadataRepresentation } from '../../../../core/shared/metadata-representation/metadata-representation.model'; import { DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD } from './models/typeahead/dynamic-typeahead.model'; import { DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN } from './models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; @@ -55,7 +58,7 @@ import { DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER } from './models/date-picker/dat import { DYNAMIC_FORM_CONTROL_TYPE_LOOKUP } from './models/lookup/dynamic-lookup.model'; import { DynamicListCheckboxGroupModel } from './models/list/dynamic-list-checkbox-group.model'; import { DynamicListRadioGroupModel } from './models/list/dynamic-list-radio-group.model'; -import { isNotEmpty, isNotUndefined } from '../../../empty.util'; +import { hasValue, isNotEmpty, isNotUndefined } from '../../../empty.util'; import { DYNAMIC_FORM_CONTROL_TYPE_LOOKUP_NAME } from './models/lookup/dynamic-lookup-name.model'; import { DsDynamicTagComponent } from './models/tag/dynamic-tag.component'; import { DsDatePickerComponent } from './models/date-picker/date-picker.component'; @@ -68,10 +71,37 @@ import { DsDynamicFormArrayComponent } from './models/array-group/dynamic-form-a import { DsDynamicRelationGroupComponent } from './models/relation-group/dynamic-relation-group.components'; import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from './models/relation-group/dynamic-relation-group.model'; import { DsDatePickerInlineComponent } from './models/date-picker-inline/dynamic-date-picker-inline.component'; +import { map, switchMap, take, tap } from 'rxjs/operators'; +import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; +import { SelectableListState } from '../../../object-list/selectable-list/selectable-list.reducer'; +import { SearchResult } from '../../../search/search-result.model'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { RelationshipService } from '../../../../core/data/relationship.service'; +import { SelectableListService } from '../../../object-list/selectable-list/selectable-list.service'; +import { DsDynamicDisabledComponent } from './models/disabled/dynamic-disabled.component'; +import { DYNAMIC_FORM_CONTROL_TYPE_DISABLED } from './models/disabled/dynamic-disabled.model'; +import { DsDynamicLookupRelationModalComponent } from './relation-lookup-modal/dynamic-lookup-relation-modal.component'; +import { + getAllSucceededRemoteData, + getRemoteDataPayload, + getSucceededRemoteData +} from '../../../../core/shared/operators'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { RemoveRelationshipAction } from './relation-lookup-modal/relationship.actions'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../../../app.reducer'; +import { SubmissionObjectDataService } from '../../../../core/submission/submission-object-data.service'; +import { SubmissionObject } from '../../../../core/submission/models/submission-object.model'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; +import { ItemMetadataRepresentation } from '../../../../core/shared/metadata-representation/item/item-metadata-representation.model'; +import { MetadataValue } from '../../../../core/shared/metadata.models'; export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type | null { switch (model.type) { - case DYNAMIC_FORM_CONTROL_TYPE_ARRAY: return DsDynamicFormArrayComponent; @@ -125,6 +155,9 @@ export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type< case DYNAMIC_FORM_CONTROL_TYPE_LOOKUP_NAME: return DsDynamicLookupComponent; + case DYNAMIC_FORM_CONTROL_TYPE_DISABLED: + return DsDynamicDisabledComponent; + default: return null; } @@ -136,8 +169,7 @@ export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type< templateUrl: './ds-dynamic-form-control-container.component.html', changeDetection: ChangeDetectionStrategy.Default }) -export class DsDynamicFormControlContainerComponent extends DynamicFormControlContainerComponent implements OnChanges { - +export class DsDynamicFormControlContainerComponent extends DynamicFormControlContainerComponent implements OnInit, OnChanges, OnDestroy { @ContentChildren(DynamicTemplateDirective) contentTemplateList: QueryList; // tslint:disable-next-line:no-input-rename @Input('templates') inputTemplateList: QueryList; @@ -150,6 +182,20 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo @Input() hasErrorMessaging = false; @Input() layout = null as DynamicFormLayout; @Input() model: any; + relationships$: Observable>>; + hasRelationLookup: boolean; + modalRef: NgbModalRef; + item: Item; + listId: string; + searchConfig: string; + selectedValues$: Observable, + mdRep: MetadataRepresentation + }>>; + /** + * List of subscriptions to unsubscribe from + */ + private subs: Subscription[] = []; /* tslint:disable:no-output-rename */ @Output('dfBlur') blur: EventEmitter = new EventEmitter(); @@ -157,7 +203,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo @Output('dfFocus') focus: EventEmitter = new EventEmitter(); @Output('ngbEvent') customEvent: EventEmitter = new EventEmitter(); /* tslint:enable:no-output-rename */ - @ViewChild('componentViewContainer', {read: ViewContainerRef}) componentViewContainerRef: ViewContainerRef; + @ViewChild('componentViewContainer', { read: ViewContainerRef }) componentViewContainerRef: ViewContainerRef; private showErrorMessagesPreviousStage: boolean; @@ -165,17 +211,69 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo return this.layoutService.getCustomComponentType(this.model) || dsDynamicFormControlMapFn(this.model); } - protected test: boolean; constructor( protected componentFactoryResolver: ComponentFactoryResolver, protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService, - protected translateService: TranslateService + protected translateService: TranslateService, + private modalService: NgbModal, + private relationService: RelationshipService, + private selectableListService: SelectableListService, + private itemService: ItemDataService, + private relationshipService: RelationshipService, + private zone: NgZone, + private store: Store, + private submissionObjectService: SubmissionObjectDataService ) { - super(componentFactoryResolver, layoutService, validationService); } + /** + * Sets up the necessary variables for when this control can be used to add relationships to the submitted item + */ + ngOnInit(): void { + this.hasRelationLookup = hasValue(this.model.relationship); + if (this.hasRelationLookup) { + this.listId = 'list-' + this.model.relationship.relationshipType; + const item$ = this.submissionObjectService + .findById(this.model.submissionId).pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()))); + + this.subs.push(item$.subscribe((item) => this.item = item)); + + this.relationService.getRelatedItemsByLabel(this.item, this.model.relationship.relationshipType).pipe( + map((items: RemoteData>) => items.payload.page.map((item) => Object.assign(new ItemSearchResult(), { indexableObject: item }))), + ).subscribe((relatedItems: Array>) => this.selectableListService.select(this.listId, relatedItems)); + + this.relationships$ = this.selectableListService.getSelectableList(this.listId).pipe( + map((listState: SelectableListState) => hasValue(listState) && hasValue(listState.selection) ? listState.selection : []), + ) as Observable>>; + this.selectedValues$ = + observableCombineLatest(item$, this.relationships$).pipe( + map(([item, relatedItems]: [Item, Array>]) => { + return relatedItems + .map((element: SearchResult) => { + const relationMD: MetadataValue = item.firstMetadata(this.model.relationship.metadataField, { value: element.indexableObject.uuid }); + if (hasValue(relationMD)) { + const metadataRepresentationMD: MetadataValue = item.firstMetadata(this.model.metadataFields, { authority: relationMD.authority }); + return { + selectedResult: element, + mdRep: Object.assign( + new ItemMetadataRepresentation(metadataRepresentationMD), + element.indexableObject + ) + }; + } + }).filter(hasValue) + } + ) + ); + + } + } + ngOnChanges(changes: SimpleChanges) { if (changes) { super.ngOnChanges(changes); @@ -212,4 +310,42 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo this.onChange(event); } } + + public hasResultsSelected(): Observable { + return this.model.value.pipe(map((list: Array>) => isNotEmpty(list))); + } + + /** + * Open a modal where the user can select relationships to be added to item being submitted + */ + openLookup() { + this.modalRef = this.modalService.open(DsDynamicLookupRelationModalComponent, { + size: 'lg' + }); + const modalComp = this.modalRef.componentInstance; + modalComp.repeatable = this.model.repeatable; + modalComp.listId = this.listId; + modalComp.relationshipOptions = this.model.relationship; + modalComp.label = this.model.label; + modalComp.metadataFields = this.model.metadataFields; + modalComp.item = this.item; + } + + /** + * Method to remove a selected relationship from the item + * @param object The second item in the relationship, the submitted item being the first + */ + removeSelection(object: SearchResult) { + this.selectableListService.deselectSingle(this.listId, object); + this.store.dispatch(new RemoveRelationshipAction(this.item, object.indexableObject, this.model.relationship.relationshipType)) + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.html new file mode 100644 index 0000000000..18fddd1446 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.html @@ -0,0 +1,13 @@ +
+
+
+ +
+
+
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.spec.ts new file mode 100644 index 0000000000..8e0c6fc20e --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.spec.ts @@ -0,0 +1,58 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { DsDynamicDisabledComponent } from './dynamic-disabled.component'; +import { FormsModule } from '@angular/forms'; +import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { DynamicDisabledModel } from './dynamic-disabled.model'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('DsDynamicDisabledComponent', () => { + let comp: DsDynamicDisabledComponent; + let fixture: ComponentFixture; + let de: DebugElement; + let el: HTMLElement; + let model; + + function init() { + model = new DynamicDisabledModel({ value: 'test', repeatable: false, metadataFields: [], submissionId: '1234', id: '1' }); + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [DsDynamicDisabledComponent], + imports: [FormsModule, TranslateModule.forRoot()], + providers: [ + { + provide: DynamicFormLayoutService, + useValue: {} + }, + { + provide: DynamicFormValidationService, + useValue: {} + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsDynamicDisabledComponent); + comp = fixture.componentInstance; // DsDynamicDisabledComponent test instance + de = fixture.debugElement; + el = de.nativeElement; + comp.model = model; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(comp).toBeTruthy(); + }); + + it('should have a disabled input', () => { + const input = de.query(By.css('input')); + console.log(input.nativeElement.getAttribute('disabled')); + expect(input.nativeElement.getAttribute('disabled')).toEqual(''); + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.ts new file mode 100644 index 0000000000..490be050ef --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component.ts @@ -0,0 +1,30 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +import { DynamicFormControlComponent, DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { FormGroup } from '@angular/forms'; +import { DynamicDisabledModel } from './dynamic-disabled.model'; + +/** + * Component representing a simple disabled input field + */ +@Component({ + selector: 'ds-dynamic-disabled', + templateUrl: './dynamic-disabled.component.html' +}) +export class DsDynamicDisabledComponent extends DynamicFormControlComponent { + + @Input() formId: string; + @Input() group: FormGroup; + @Input() model: DynamicDisabledModel; + modelValuesString = ''; + + @Output() blur: EventEmitter = new EventEmitter(); + @Output() change: EventEmitter = new EventEmitter(); + @Output() focus: EventEmitter = new EventEmitter(); + + constructor(protected layoutService: DynamicFormLayoutService, + protected validationService: DynamicFormValidationService + ) { + super(layoutService, validationService); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.model.ts new file mode 100644 index 0000000000..0fa2b3e5ed --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.model.ts @@ -0,0 +1,24 @@ +import { DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; +import { DsDynamicInputModel, DsDynamicInputModelConfig } from '../ds-dynamic-input.model'; + +export const DYNAMIC_FORM_CONTROL_TYPE_DISABLED = 'EMPTY'; + +export interface DsDynamicDisabledModelConfig extends DsDynamicInputModelConfig { + value?: any; +} + +/** + * This model represents the data for a disabled input field + */ +export class DynamicDisabledModel extends DsDynamicInputModel { + + @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_DISABLED; + @serializable() value: any; + + constructor(config: DsDynamicDisabledModelConfig, layout?: DynamicFormControlLayout) { + super(config, layout); + this.readOnly = true; + this.disabled = true; + this.valueUpdates.next(config.value); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts index 66bdf97dad..af05d5bf35 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts @@ -5,6 +5,7 @@ import { Subject } from 'rxjs'; import { isNotEmpty } from '../../../../empty.util'; import { DsDynamicInputModel } from './ds-dynamic-input.model'; import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; +import { RelationshipOptions } from '../../models/relationship-options.model'; export const CONCAT_GROUP_SUFFIX = '_CONCAT_GROUP'; export const CONCAT_FIRST_INPUT_SUFFIX = '_CONCAT_FIRST_INPUT'; @@ -12,12 +13,24 @@ export const CONCAT_SECOND_INPUT_SUFFIX = '_CONCAT_SECOND_INPUT'; export interface DynamicConcatModelConfig extends DynamicFormGroupModelConfig { separator: string; + value?: any; + relationship?: RelationshipOptions; + repeatable: boolean; + required: boolean; + metadataFields: string[]; + submissionId: string; } export class DynamicConcatModel extends DynamicFormGroupModel { @serializable() separator: string; @serializable() hasLanguages = false; + @serializable() relationship?: RelationshipOptions; + @serializable() repeatable?: boolean; + @serializable() required?: boolean; + @serializable() metadataFields: string[]; + @serializable() submissionId: string; + isCustomGroup = true; valueUpdates: Subject; @@ -26,6 +39,11 @@ export class DynamicConcatModel extends DynamicFormGroupModel { super(config, layout); this.separator = config.separator + ' '; + this.relationship = config.relationship; + this.repeatable = config.repeatable; + this.required = config.required; + this.metadataFields = config.metadataFields; + this.submissionId = config.submissionId; this.valueUpdates = new Subject(); this.valueUpdates.subscribe((value: string) => this.value = value); @@ -49,7 +67,7 @@ export class DynamicConcatModel extends DynamicFormGroupModel { let tempValue: string; if (typeof value === 'string') { - tempValue = value; + tempValue = value; } else { tempValue = value.value; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts index 4e4a944319..3827df7be6 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts @@ -1,21 +1,21 @@ -import { - DynamicFormControlLayout, - DynamicInputModel, - DynamicInputModelConfig, - serializable -} from '@ng-dynamic-forms/core'; +import { DynamicFormControlLayout, DynamicInputModel, DynamicInputModelConfig, serializable } from '@ng-dynamic-forms/core'; import { Subject } from 'rxjs'; import { LanguageCode } from '../../models/form-field-language-value.model'; import { AuthorityOptions } from '../../../../../core/integration/models/authority-options.model'; import { hasValue } from '../../../../empty.util'; import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; +import { RelationshipOptions } from '../../models/relationship-options.model'; export interface DsDynamicInputModelConfig extends DynamicInputModelConfig { authorityOptions?: AuthorityOptions; languageCodes?: LanguageCode[]; language?: string; value?: any; + relationship?: RelationshipOptions; + repeatable: boolean; + metadataFields: string[]; + submissionId: string; } export class DsDynamicInputModel extends DynamicInputModel { @@ -24,13 +24,21 @@ export class DsDynamicInputModel extends DynamicInputModel { @serializable() private _languageCodes: LanguageCode[]; @serializable() private _language: string; @serializable() languageUpdates: Subject; + @serializable() relationship?: RelationshipOptions; + @serializable() repeatable?: boolean; + @serializable() metadataFields: string[]; + @serializable() submissionId: string; constructor(config: DsDynamicInputModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); - + this.repeatable = config.repeatable; + this.metadataFields = config.metadataFields; this.hint = config.hint; this.readOnly = config.readOnly; this.value = config.value; + this.relationship = config.relationship; + this.submissionId = config.submissionId; + this.language = config.language; if (!this.language) { // TypeAhead @@ -79,5 +87,4 @@ export class DsDynamicInputModel extends DynamicInputModel { this.language = this.languageCodes ? this.languageCodes[0].code : null; } } - } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts index 5d2cbc58b7..a2ed83f6c1 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts @@ -12,6 +12,7 @@ export interface DsDynamicQualdropModelConfig extends DynamicFormGroupModelConfi languageCodes?: LanguageCode[]; language?: string; readOnly: boolean; + required: boolean; hint?: string; } @@ -22,12 +23,14 @@ export class DynamicQualdropModel extends DynamicFormGroupModel { @serializable() hasLanguages = false; @serializable() readOnly: boolean; @serializable() hint: string; + @serializable() required: boolean; isCustomGroup = true; constructor(config: DsDynamicQualdropModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); this.readOnly = config.readOnly; + this.required = config.required; this.language = config.language; this.languageCodes = config.languageCodes; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts index b91af8f0c9..7de319bf56 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts @@ -1,21 +1,18 @@ -import { - DYNAMIC_FORM_CONTROL_TYPE_ARRAY, - DynamicFormArrayModel, DynamicFormArrayModelConfig, DynamicFormControlLayout, - serializable -} from '@ng-dynamic-forms/core'; -import { DYNAMIC_FORM_CONTROL_TYPE_TAG } from './tag/dynamic-tag.model'; +import { DynamicFormArrayModel, DynamicFormArrayModelConfig, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; export interface DynamicRowArrayModelConfig extends DynamicFormArrayModelConfig { notRepeatable: boolean; + required: boolean; } export class DynamicRowArrayModel extends DynamicFormArrayModel { @serializable() notRepeatable = false; + @serializable() required = false; isRowArray = true; constructor(config: DynamicRowArrayModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); this.notRepeatable = config.notRepeatable; + this.required = config.required; } - } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts index 39c2c61efe..b0ed3a1dc2 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts @@ -27,6 +27,8 @@ import { AuthorityConfidenceStateDirective } from '../../../../../authority-conf import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../../../../../config'; import { MOCK_SUBMISSION_CONFIG } from '../../../../../testing/mock-submission-config'; +import { WorkspaceitemsEditPageModule } from '../../../../../../+workspaceitems-edit-page/workspaceitems-edit-page.module'; +import { WorkspaceItem } from '../../../../../../core/submission/models/workspaceitem.model'; let LOOKUP_TEST_MODEL_CONFIG = { authorityOptions: { @@ -47,7 +49,9 @@ let LOOKUP_TEST_MODEL_CONFIG = { repeatable: true, separator: ',', validators: { required: null }, - value: undefined + value: undefined, + metadataFields: [], + submissionId: '1234' }; let LOOKUP_NAME_TEST_MODEL_CONFIG = { @@ -69,7 +73,9 @@ let LOOKUP_NAME_TEST_MODEL_CONFIG = { repeatable: true, separator: ',', validators: { required: null }, - value: undefined + value: undefined, + metadataFields: [], + submissionId: '1234' }; let LOOKUP_TEST_GROUP = new FormGroup({ @@ -100,7 +106,9 @@ describe('Dynamic Lookup component', () => { repeatable: true, separator: ',', validators: { required: null }, - value: undefined + value: undefined, + metadataFields: [], + submissionId: '1234' }; LOOKUP_NAME_TEST_MODEL_CONFIG = { @@ -122,7 +130,9 @@ describe('Dynamic Lookup component', () => { repeatable: true, separator: ',', validators: { required: null }, - value: undefined + value: undefined, + metadataFields: [], + submissionId: '1234' }; LOOKUP_TEST_GROUP = new FormGroup({ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts index 6d5839f867..75d30d9d79 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts @@ -33,6 +33,8 @@ export let FORM_GROUP_TEST_GROUP; const config: GlobalConfig = MOCK_SUBMISSION_CONFIG; +const submissionId = '1234'; + function init() { FORM_GROUP_TEST_MODEL_CONFIG = { disabled: false, @@ -67,6 +69,7 @@ function init() { }] } as FormFieldModel] } as FormRowModel], + submissionId, id: 'dc_contributor_author', label: 'Authors', mandatoryField: 'dc.contributor.author', @@ -77,7 +80,9 @@ function init() { required: true, scopeUUID: '43fe1f8c-09a6-4fcf-9c78-5d4fed8f2c8f', submissionScope: undefined, - validators: { required: null } + validators: { required: null }, + repeatable: false, + metadataFields: [] } as DynamicRelationGroupModelConfig; FORM_GROUP_TEST_GROUP = new FormGroup({ @@ -183,7 +188,7 @@ describe('DsDynamicRelationGroupComponent test suite', () => { it('should init component properly', inject([FormBuilderService], (service: FormBuilderService) => { const formConfig = { rows: groupComp.model.formConfiguration } as SubmissionFormsModel; - const formModel = service.modelFromConfiguration(formConfig, groupComp.model.scopeUUID, {}, groupComp.model.submissionScope, groupComp.model.readOnly); + const formModel = service.modelFromConfiguration(submissionId, formConfig, groupComp.model.scopeUUID, {}, groupComp.model.submissionScope, groupComp.model.readOnly); const chips = new Chips([], 'value', 'dc.contributor.author'); groupComp.formCollapsed.subscribe((value) => { expect(value).toEqual(false); @@ -257,11 +262,11 @@ describe('DsDynamicRelationGroupComponent test suite', () => { it('should init component properly', inject([FormBuilderService], (service: FormBuilderService) => { const formConfig = { rows: groupComp.model.formConfiguration } as SubmissionFormsModel; - const formModel = service.modelFromConfiguration(formConfig, groupComp.model.scopeUUID, {}, groupComp.model.submissionScope, groupComp.model.readOnly); + const formModel = service.modelFromConfiguration(submissionId, formConfig, groupComp.model.scopeUUID, {}, groupComp.model.submissionScope, groupComp.model.readOnly); const chips = new Chips(modelValue, 'value', 'dc.contributor.author'); groupComp.formCollapsed.subscribe((value) => { expect(value).toEqual(true); - }) + }); expect(groupComp.formModel.length).toEqual(formModel.length); expect(groupComp.chips.getChipsItems()).toEqual(chips.getChipsItems()); })); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts index 62b6b4effa..ea62eeb4ce 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts @@ -93,6 +93,7 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent this.formId = this.formService.getUniqueId(this.model.id); this.formModel = this.formBuilderService.modelFromConfiguration( + this.model.submissionId, config, this.model.scopeUUID, {}, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model.ts index e6d2b95afc..c1f76f0431 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model.ts @@ -10,6 +10,7 @@ export const PLACEHOLDER_PARENT_METADATA = '#PLACEHOLDER_PARENT_METADATA_VALUE#' * Dynamic Group Model configuration interface */ export interface DynamicRelationGroupModelConfig extends DsDynamicInputModelConfig { + submissionId: string, formConfiguration: FormRowModel[], mandatoryField: string, relationFields: string[], @@ -21,6 +22,7 @@ export interface DynamicRelationGroupModelConfig extends DsDynamicInputModelConf * Dynamic Group Model class */ export class DynamicRelationGroupModel extends DsDynamicInputModel { + @serializable() submissionId: string; @serializable() formConfiguration: FormRowModel[]; @serializable() mandatoryField: string; @serializable() relationFields: string[]; @@ -32,6 +34,7 @@ export class DynamicRelationGroupModel extends DsDynamicInputModel { constructor(config: DynamicRelationGroupModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); + this.submissionId = config.submissionId; this.formConfiguration = config.formConfiguration; this.mandatoryField = config.mandatoryField; this.relationFields = config.relationFields; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts index 2bcb42a73a..ab923a58fa 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts @@ -38,7 +38,9 @@ export const SD_TEST_MODEL_CONFIG = { readOnly: false, required: false, repeatable: false, - value: undefined + value: undefined, + metadataFields: [], + submissionId: '1234' }; describe('Dynamic Dynamic Scrollable Dropdown component', () => { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html new file mode 100644 index 0000000000..52f983e723 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html @@ -0,0 +1,45 @@ + + + \ No newline at end of file diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.scss new file mode 100644 index 0000000000..4fb77a7590 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.scss @@ -0,0 +1,3 @@ +.modal-footer { + justify-content: space-between; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.spec.ts new file mode 100644 index 0000000000..a4f77fd364 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.spec.ts @@ -0,0 +1,129 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgZone, NO_ERRORS_SCHEMA } from '@angular/core'; +import { of as observableOf, Subscription } from 'rxjs'; +import { DsDynamicLookupRelationModalComponent } from './dynamic-lookup-relation-modal.component'; +import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { RelationshipTypeService } from '../../../../../core/data/relationship-type.service'; +import { Store } from '@ngrx/store'; +import { Item } from '../../../../../core/shared/item.model'; +import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model'; +import { RelationshipOptions } from '../../models/relationship-options.model'; +import { AddRelationshipAction, RemoveRelationshipAction } from './relationship.actions'; + +describe('DsDynamicLookupRelationModalComponent', () => { + let component: DsDynamicLookupRelationModalComponent; + let fixture: ComponentFixture; + let item; + let item1; + let item2; + let searchResult1; + let searchResult2; + let listID; + let selection$; + let selectableListService; + let relationship; + let nameVariant; + let metadataField; + + function init() { + item = Object.assign(new Item(), { uuid: '7680ca97-e2bd-4398-bfa7-139a8673dc42', metadata: {} }); + item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); + item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); + searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); + searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); + listID = '6b0c8221-fcb4-47a8-b483-ca32363fffb3'; + selection$ = observableOf([searchResult1, searchResult2]); + selectableListService = { getSelectableList: () => selection$ }; + relationship = { filter: 'filter', relationshipType: 'isAuthorOfPublication', nameVariants: true } as RelationshipOptions; + nameVariant = 'Doe, J.'; + metadataField = 'dc.contributor.author'; + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [DsDynamicLookupRelationModalComponent], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule.forRoot()], + providers: [ + { + provide: SelectableListService, useValue: selectableListService + }, + { + provide: RelationshipService, useValue: { getNameVariant: () => observableOf(nameVariant) } + }, + { provide: RelationshipTypeService, useValue: {} }, + { + provide: Store, useValue: { + // tslint:disable-next-line:no-empty + dispatch: () => {} + } + }, + { provide: NgZone, useValue: new NgZone({}) }, + NgbActiveModal + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsDynamicLookupRelationModalComponent); + component = fixture.componentInstance; + component.listId = listID; + component.relationshipOptions = relationship; + component.item = item; + component.metadataFields = metadataField; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('close', () => { + beforeEach(() => { + spyOn(component.modal, 'close'); + }); + + it('should call close on the modal', () => { + component.close(); + expect(component.modal.close).toHaveBeenCalled(); + }) + }); + + describe('select', () => { + beforeEach(() => { + spyOn((component as any).store, 'dispatch'); + }); + + it('should dispatch an AddRelationshipAction for each selected object', () => { + component.select(searchResult1, searchResult2); + const action = new AddRelationshipAction(component.item, searchResult1.indexableObject, relationship.relationshipType, nameVariant); + const action2 = new AddRelationshipAction(component.item, searchResult2.indexableObject, relationship.relationshipType, nameVariant); + + expect((component as any).store.dispatch).toHaveBeenCalledWith(action); + expect((component as any).store.dispatch).toHaveBeenCalledWith(action2); + }) + }); + + describe('deselect', () => { + beforeEach(() => { + component.subMap[searchResult1.indexableObject.uuid] = new Subscription(); + component.subMap[searchResult2.indexableObject.uuid] = new Subscription(); + spyOn((component as any).store, 'dispatch'); + }); + + it('should dispatch an RemoveRelationshipAction for each deselected object', () => { + component.deselect(searchResult1, searchResult2); + const action = new RemoveRelationshipAction(component.item, searchResult1.indexableObject, relationship.relationshipType); + const action2 = new RemoveRelationshipAction(component.item, searchResult2.indexableObject, relationship.relationshipType); + + expect((component as any).store.dispatch).toHaveBeenCalledWith(action); + expect((component as any).store.dispatch).toHaveBeenCalledWith(action2); + }) + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts new file mode 100644 index 0000000000..f3ed3337a9 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts @@ -0,0 +1,157 @@ +import { Component, NgZone, OnDestroy, OnInit } from '@angular/core'; +import { combineLatest, Observable, Subscription } from 'rxjs'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { hasValue } from '../../../../empty.util'; +import { map, skip, switchMap, take } from 'rxjs/operators'; +import { SEARCH_CONFIG_SERVICE } from '../../../../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../../core/shared/search/search-configuration.service'; +import { SelectableListService } from '../../../../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 { RelationshipOptions } from '../../models/relationship-options.model'; +import { SearchResult } from '../../../../search/search-result.model'; +import { Item } from '../../../../../core/shared/item.model'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../../core/shared/operators'; +import { AddRelationshipAction, RemoveRelationshipAction, UpdateRelationshipAction } from './relationship.actions'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { RelationshipTypeService } from '../../../../../core/data/relationship-type.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../../../../app.reducer'; +import { Context } from '../../../../../core/shared/context.model'; +import { Relationship } from '../../../../../core/shared/item-relationships/relationship.model'; +import { MetadataValue } from '../../../../../core/shared/metadata.models'; + +@Component({ + selector: 'ds-dynamic-lookup-relation-modal', + styleUrls: ['./dynamic-lookup-relation-modal.component.scss'], + templateUrl: './dynamic-lookup-relation-modal.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) + +/** + * Represents a modal where the submitter can select items to be added as a certain relationship type to the object being submitted + */ +export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy { + label: string; + relationshipOptions: RelationshipOptions; + listId: string; + item; + repeatable: boolean; + selection$: Observable; + context: Context; + metadataFields: string; + subMap: { + [uuid: string]: Subscription + } = {}; + + constructor( + public modal: NgbActiveModal, + private selectableListService: SelectableListService, + private relationshipService: RelationshipService, + private relationshipTypeService: RelationshipTypeService, + private zone: NgZone, + private store: Store + ) { + } + + ngOnInit(): void { + this.selection$ = this.selectableListService + .getSelectableList(this.listId) + .pipe(map((listState: SelectableListState) => hasValue(listState) && hasValue(listState.selection) ? listState.selection : [])); + this.selection$.pipe(take(1)).subscribe((selection) => + selection.map((s: SearchResult) => this.addNameVariantSubscription(s)) + ); + if (this.relationshipOptions.nameVariants) { + this.context = Context.SubmissionModal; + } + + // this.setExistingNameVariants(); + } + + close() { + this.modal.close(); + } + + select(...selectableObjects: Array>) { + this.zone.runOutsideAngular( + () => { + const obs: Observable = combineLatest(...selectableObjects.map((sri: SearchResult) => { + this.addNameVariantSubscription(sri); + return this.relationshipService.getNameVariant(this.listId, sri.indexableObject.uuid) + .pipe( + take(1), + map((nameVariant: string) => { + return { + item: sri.indexableObject, + nameVariant + } + }) + ) + }) + ); + obs + .subscribe((arr: any[]) => { + return arr.forEach((object: any) => { + this.store.dispatch(new AddRelationshipAction(this.item, object.item, this.relationshipOptions.relationshipType, object.nameVariant)); + } + ); + }) + }); + } + + private addNameVariantSubscription(sri: SearchResult) { + const nameVariant$ = this.relationshipService.getNameVariant(this.listId, sri.indexableObject.uuid); + this.subMap[sri.indexableObject.uuid] = nameVariant$.pipe( + skip(1), + ).subscribe((nameVariant: string) => this.store.dispatch(new UpdateRelationshipAction(this.item, sri.indexableObject, this.relationshipOptions.relationshipType, nameVariant))) + } + + deselect(...selectableObjects: Array>) { + this.zone.runOutsideAngular( + () => selectableObjects.forEach((object) => { + this.subMap[object.indexableObject.uuid].unsubscribe(); + this.store.dispatch(new RemoveRelationshipAction(this.item, object.indexableObject, this.relationshipOptions.relationshipType)); + }) + ); + } + + private setExistingNameVariants() { + const virtualMDs: MetadataValue[] = this.item.allMetadata(this.metadataFields).filter((mdValue) => mdValue.isVirtual); + + const relatedItemPairs$: Observable> = + combineLatest(virtualMDs.map((md: MetadataValue) => this.relationshipService.findById(md.virtualValue).pipe(getSucceededRemoteData(), getRemoteDataPayload()))) + .pipe( + switchMap((relationships: Relationship[]) => combineLatest(relationships.map((relationship: Relationship) => + combineLatest( + relationship.leftItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()), + relationship.rightItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()) + )) + ) + ) + ); + + const relatedItems$: Observable = relatedItemPairs$.pipe( + map(([relatedItemPairs,]: [Array<[Item, Item]>]) => relatedItemPairs.map(([left, right]: [Item, Item]) => left.uuid === this.item.uuid ? left : right)) + ); + + relatedItems$.pipe(take(1)).subscribe((relatedItems) => { + let index = 0; + virtualMDs.forEach( + (md: MetadataValue) => { + this.relationshipService.setNameVariant(this.listId, relatedItems[index].uuid, md.value); + index++; + } + ); + } + ) + } + + ngOnDestroy() { + Object.values(this.subMap).forEach((subscription) => subscription.unsubscribe()); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions.ts new file mode 100644 index 0000000000..f32836eef1 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions.ts @@ -0,0 +1,62 @@ +/** + * The list of NameVariantAction type definitions + */ +import { type } from '../../../../ngrx/type'; +import { Action } from '@ngrx/store'; +import { Item } from '../../../../../core/shared/item.model'; + +export const NameVariantActionTypes = { + SET_NAME_VARIANT: type('dspace/name-variant/SET_NAME_VARIANT'), + REMOVE_NAME_VARIANT: type('dspace/name-variant/REMOVE_NAME_VARIANT'), +}; + +/* tslint:disable:max-classes-per-file */ +/** + * Abstract class for actions that happen to name variants + */ +export abstract class NameVariantListAction implements Action { + type; + payload: { + listID: string; + itemID: string; + }; + + constructor(listID: string, itemID: string) { + this.payload = { listID, itemID }; + } +} + +/** + * Action for setting a new name on an item in a certain list + */ +export class SetNameVariantAction extends NameVariantListAction { + type = NameVariantActionTypes.SET_NAME_VARIANT; + payload: { + listID: string; + itemID: string; + nameVariant: string; + }; + + constructor(listID: string, itemID: string, nameVariant: string) { + super(listID, itemID); + this.payload.nameVariant = nameVariant; + } +} + +/** + * Action for removing a name on an item in a certain list + */ +export class RemoveNameVariantAction extends NameVariantListAction { + type = NameVariantActionTypes.REMOVE_NAME_VARIANT; + constructor(listID: string, itemID: string) { + super(listID, itemID); + } +} +/* tslint:enable:max-classes-per-file */ + +/** + * A type to encompass all RelationshipActions + */ +export type NameVariantAction + = SetNameVariantAction + | RemoveNameVariantAction diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer.spec.ts new file mode 100644 index 0000000000..1ac6cd55d1 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer.spec.ts @@ -0,0 +1,93 @@ +import * as deepFreeze from 'deep-freeze'; +import { NameVariantAction, RemoveNameVariantAction, SetNameVariantAction } from './name-variant.actions'; +import { Action } from '@ngrx/store'; +import { nameVariantReducer } from './name-variant.reducer'; + +class NullAction implements Action { + type = null; +} + +let listID1; +let listID2; +let itemID1; +let itemID2; +let variantList1Item1; +let variantList1Item1Update; +let variantList1Item2; + +function init() { + listID1 = 'dbfb81de-2930-4de6-ba2e-ea21c8534ee9'; + listID2 = 'd7f2c48d-e1e2-4996-ab8d-e271cabec78a'; + itemID1 = 'd1c81d4f-6b05-4844-986b-372d2e39c6aa'; + itemID2 = 'fe4ca421-d897-417f-9436-9724262d5c69'; + variantList1Item1 = 'Test Name Variant 1'; + variantList1Item1Update = 'Test Name Variant 1 Update'; + variantList1Item2 = 'Test Name Variant 2'; +} + +describe('nameVariantReducer', () => { + beforeEach(() => { + init(); + }); + + it('should return the current state when no valid actions have been made', () => { + const state = { [listID1]: { [itemID1]: variantList1Item1 } }; + const action = new NullAction() as any; + const newState = nameVariantReducer(state, action); + + expect(newState).toEqual(state); + }); + + it('should start with an empty object', () => { + const state = Object.create({}); + const action = new NullAction() as any; + const initialState = nameVariantReducer(undefined, action); + + // The search filter starts collapsed + expect(initialState).toEqual(state); + }); + + it('should set add a new name variant in response to the SET_NAME_VARIANT' + + ' action with a combination of list and item ID that does not exist yet', () => { + const state = {}; + state[listID1] = { [itemID1]: variantList1Item1 }; + const action = new SetNameVariantAction(listID1, itemID2, variantList1Item2); + const newState = nameVariantReducer(state, action); + + expect(newState[listID1][itemID1]).toEqual(variantList1Item1); + expect(newState[listID1][itemID2]).toEqual(variantList1Item2); + }); + + it('should set a name variant in response to the SET_NAME_VARIANT' + + ' action with a combination of list and item ID that already exists', () => { + const state = {}; + state[listID1] = { [itemID1]: variantList1Item1 }; + const action = new SetNameVariantAction(listID1, itemID1, variantList1Item1Update); + const newState = nameVariantReducer(state, action); + + expect(newState[listID1][itemID1]).toEqual(variantList1Item1Update); + }); + + it('should remove a name variant in response to the REMOVE_NAME_VARIANT' + + ' action with a combination of list and item ID that already exists', () => { + const state = {}; + state[listID1] = { [itemID1]: variantList1Item1 }; + expect(state[listID1][itemID1]).toEqual(variantList1Item1); + + const action = new RemoveNameVariantAction(listID1, itemID1); + const newState = nameVariantReducer(state, action); + + expect(newState[listID1][itemID1]).toBeUndefined(); + }); + + it('should do nothing in response to the REMOVE_NAME_VARIANT' + + ' action with a combination of list and item ID that does not exists', () => { + const state = {}; + state[listID1] = { [itemID1]: variantList1Item1 }; + + const action = new RemoveNameVariantAction(listID2, itemID1); + const newState = nameVariantReducer(state, action); + + expect(newState).toEqual(state); + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer.ts new file mode 100644 index 0000000000..9f93cf2dd1 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer.ts @@ -0,0 +1,50 @@ +/** + * Represents the state of all lists containing name variants in the store + */ + +import { NameVariantAction, NameVariantActionTypes, SetNameVariantAction } from './name-variant.actions'; +import { hasValue } from '../../../../empty.util'; + +export interface NameVariantListsState { + [listID: string]: NameVariantListState; +} + +/** + * Represents the state of a single list containing nameVariants in the store + */ +export interface NameVariantListState { + [itemID: string]: string; +} + +/** + * Reducer that handles NameVariantAction to update the NameVariantListsState + * @param {NameVariantListsState} state The initial NameVariantListsState + * @param {NameVariantAction} action The Action to be performed on the state + * @returns {NameVariantListsState} The new, reduced NameVariantListsState + */ +export function nameVariantReducer(state: NameVariantListsState = {}, action: NameVariantAction): NameVariantListsState { + switch (action.type) { + case NameVariantActionTypes.SET_NAME_VARIANT: { + const listState: NameVariantListState = state[action.payload.listID] || {}; + const nameVariant = (action as SetNameVariantAction).payload.nameVariant; + const newListState = setNameVariant(listState, action.payload.itemID, nameVariant); + return Object.assign({}, state, { [action.payload.listID]: newListState }); + } + case NameVariantActionTypes.REMOVE_NAME_VARIANT: { + const listState: NameVariantListState = state[action.payload.listID]; + if (hasValue(listState) && hasValue(listState[action.payload.itemID])) { + const newListState = setNameVariant(listState, action.payload.itemID, undefined); + return Object.assign({}, state, { [action.payload.listID]: newListState }); + } else { + return state; + } + } + default: { + return state; + } + } +} + +function setNameVariant(state: NameVariantListState, itemID: string, nameVariant: string) { + return Object.assign({}, state, { [itemID]: nameVariant }); +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.actions.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.actions.ts new file mode 100644 index 0000000000..57375bd380 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.actions.ts @@ -0,0 +1,108 @@ +/** + * The list of RelationshipAction type definitions + */ +import { type } from '../../../../ngrx/type'; +import { Action } from '@ngrx/store'; +import { Item } from '../../../../../core/shared/item.model'; + +export const RelationshipActionTypes = { + ADD_RELATIONSHIP: type('dspace/relationship/ADD_RELATIONSHIP'), + REMOVE_RELATIONSHIP: type('dspace/relationship/REMOVE_RELATIONSHIP'), + UPDATE_RELATIONSHIP: type('dspace/relationship/UPDATE_RELATIONSHIP'), +}; + +/* tslint:disable:max-classes-per-file */ +/** + * An ngrx action to create a new relationship + */ +export class AddRelationshipAction implements Action { + type = RelationshipActionTypes.ADD_RELATIONSHIP; + + payload: { + item1: Item; + item2: Item; + relationshipType: string; + nameVariant: string; + }; + + /** + * Create a new AddRelationshipAction + * + * @param item1 The first item in the relationship + * @param item2 The second item in the relationship + * @param relationshipType The label of the relationshipType + * @param nameVariant The nameVariant of the relationshipType + */ + constructor( + item1: Item, + item2: Item, + relationshipType: string, + nameVariant?: string + ) { + this.payload = { item1, item2, relationshipType, nameVariant }; + } +} + +export class UpdateRelationshipAction implements Action { + type = RelationshipActionTypes.UPDATE_RELATIONSHIP; + + payload: { + item1: Item; + item2: Item; + relationshipType: string; + nameVariant: string; + }; + + /** + * Create a new UpdateRelationshipAction + * + * @param item1 The first item in the relationship + * @param item2 The second item in the relationship + * @param relationshipType The label of the relationshipType + * @param nameVariant The nameVariant of the relationshipType + */ + constructor( + item1: Item, + item2: Item, + relationshipType: string, + nameVariant?: string + ) { + this.payload = { item1, item2, relationshipType, nameVariant }; + } +} + +/** + * An ngrx action to remove an existing relationship + */ +export class RemoveRelationshipAction implements Action { + type = RelationshipActionTypes.REMOVE_RELATIONSHIP; + + payload: { + item1: Item; + item2: Item; + relationshipType: string; + }; + + /** + * Create a new RemoveRelationshipAction + * + * @param item1 The first item in the relationship + * @param item2 The second item in the relationship + * @param relationshipType The label of the relationshipType + */ + constructor( + item1: Item, + item2: Item, + relationshipType: string) { + this.payload = { item1, item2, relationshipType }; + } +} + +/* tslint:enable:max-classes-per-file */ + +/** + * A type to encompass all RelationshipActions + */ +export type RelationshipAction + = AddRelationshipAction + | RemoveRelationshipAction diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.spec.ts new file mode 100644 index 0000000000..f9d7dabf9c --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.spec.ts @@ -0,0 +1,256 @@ +import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; +import { RelationshipEffects } from './relationship.effects'; +import { async, TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { AddRelationshipAction, RelationshipActionTypes, RemoveRelationshipAction } from './relationship.actions'; +import { Item } from '../../../../../core/shared/item.model'; +import { MetadataValue } from '../../../../../core/shared/metadata.models'; +import { RelationshipTypeService } from '../../../../../core/data/relationship-type.service'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { Relationship } from '../../../../../core/shared/item-relationships/relationship.model'; +import { createSuccessfulRemoteDataObject$, spyOnOperator } from '../../../../testing/utils'; +import { RelationshipType } from '../../../../../core/shared/item-relationships/relationship-type.model'; +import { cold, hot } from 'jasmine-marbles'; +import * as operators from 'rxjs/operators'; +import { last } from 'rxjs/operators'; +import { ItemType } from '../../../../../core/shared/item-relationships/item-type.model'; +import { RestResponse } from '../../../../../core/cache/response.models'; + +describe('RelationshipEffects', () => { + let relationEffects: RelationshipEffects; + let actions: Observable; + + let testUUID1; + let testUUID2; + let leftTypeString; + let rightTypeString; + let leftType; + let rightType; + let leftTypeMD; + let rightTypeMD; + let relationshipID; + let identifier; + + let leftItem; + + let rightItem; + + let relationshipType: RelationshipType; + + let relationship; + let mockRelationshipService; + let mockRelationshipTypeService; + + function init() { + testUUID1 = '20e24c2f-a00a-467c-bdee-c929e79bf08d'; + testUUID2 = '7f66a4d0-8557-4e77-8b1e-19930895f10a'; + leftTypeString = 'Publication'; + rightTypeString = 'Person'; + leftType = Object.assign(new ItemType(), {label: leftTypeString}); + rightType = Object.assign(new ItemType(), {label: rightTypeString}); + leftTypeMD = Object.assign(new MetadataValue(), { value: leftTypeString }); + rightTypeMD = Object.assign(new MetadataValue(), { value: rightTypeString }); + relationshipID = '1234'; + + leftItem = Object.assign(new Item(), { + uuid: testUUID1, + metadata: { 'relationship.type': [leftTypeMD] } + }); + + rightItem = Object.assign(new Item(), { + uuid: testUUID2, + metadata: { 'relationship.type': [rightTypeMD] } + }); + + relationshipType = Object.assign(new RelationshipType(), { + leftwardType: 'isAuthorOfPublication', + rightwardType: 'isPublicationOfAuthor', + leftType: createSuccessfulRemoteDataObject$(leftType), + rightType: createSuccessfulRemoteDataObject$(rightType) + }); + + relationship = Object.assign(new Relationship(), + { + uuid: relationshipID, + id: relationshipID, + leftItem: createSuccessfulRemoteDataObject$(leftItem), + rightItem: createSuccessfulRemoteDataObject$(rightItem), + relationshipType: createSuccessfulRemoteDataObject$(relationshipType) + }); + + mockRelationshipService = { + getRelationshipByItemsAndLabel: + () => observableOf(relationship), + deleteRelationship: () => observableOf(new RestResponse(true, 200, 'OK')), + addRelationship: () => observableOf(new RestResponse(true, 200, 'OK')) + + }; + mockRelationshipTypeService = { + getRelationshipTypeByLabelAndTypes: + () => observableOf(relationshipType) + }; + } + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + providers: [ + RelationshipEffects, + provideMockActions(() => actions), + { provide: RelationshipTypeService, useValue: mockRelationshipTypeService }, + { provide: RelationshipService, useValue: mockRelationshipService } + ], + }); + })); + + beforeEach(() => { + relationEffects = TestBed.get(RelationshipEffects); + identifier = (relationEffects as any).createIdentifier(leftItem, rightItem, relationshipType.leftwardType); + }); + + describe('mapLastActions$', () => { + describe('When an ADD_RELATIONSHIP action is triggered', () => { + describe('When it\'s the first time for this identifier', () => { + let action; + it('should set the current value debounceMap and the value of the initialActionMap to ADD_RELATIONSHIP', () => { + action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--a-', { a: action }); + const expected = cold('--b-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + + expect((relationEffects as any).initialActionMap[identifier]).toBe(action.type); + expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type); + }); + }); + + describe('When it\'s not the first time for this identifier', () => { + let action; + const testActionType = 'TEST_TYPE'; + beforeEach(() => { + (relationEffects as any).initialActionMap[identifier] = testActionType; + (relationEffects as any).debounceMap[identifier] = new BehaviorSubject(testActionType); + }); + + it('should set the current value debounceMap to ADD_RELATIONSHIP but not change the value of the initialActionMap', () => { + action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--a-', { a: action }); + const expected = cold('--b-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + + expect((relationEffects as any).initialActionMap[identifier]).toBe(testActionType); + expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type); + }); + }); + + describe('When the initialActionMap contains an ADD_RELATIONSHIP action', () => { + let action; + describe('When the last value in the debounceMap is also an ADD_RELATIONSHIP action', () => { + beforeEach(() => { + (relationEffects as any).initialActionMap[identifier] = RelationshipActionTypes.ADD_RELATIONSHIP; + spyOnOperator(operators, 'debounceTime').and.returnValue((v) => v); + spyOn((relationEffects as any), 'addRelationship'); + }); + it('should call addRelationship on the effect', () => { + action = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--a-', { a: action }); + const expected = cold('--b-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + expect((relationEffects as any).addRelationship).toHaveBeenCalledWith(leftItem, rightItem, relationshipType.leftwardType, undefined) + }); + }); + + describe('When the last value in the debounceMap is instead a REMOVE_RELATIONSHIP action', () => { + beforeEach(() => { + /** + * Change debounceTime to last so there's no need to fire a certain amount of actions in the debounce time frame + */ + spyOnOperator(operators, 'debounceTime').and.returnValue((v) => v.pipe(last())); + spyOn((relationEffects as any), 'addRelationship'); + spyOn((relationEffects as any), 'removeRelationship'); + }); + it('should not call removeRelationship or addRelationship on the effect', () => { + const actiona = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + const actionb = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--ab-', { a: actiona, b: actionb }); + const expected = cold('--bb-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + expect((relationEffects as any).addRelationship).not.toHaveBeenCalled(); + expect((relationEffects as any).removeRelationship).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('When an REMOVE_RELATIONSHIP action is triggered', () => { + describe('When it\'s the first time for this identifier', () => { + let action; + it('should set the current value debounceMap and the value of the initialActionMap to REMOVE_RELATIONSHIP', () => { + action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--a-', { a: action }); + const expected = cold('--b-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + + expect((relationEffects as any).initialActionMap[identifier]).toBe(action.type); + expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type); + }); + }); + + describe('When it\'s not the first time for this identifier', () => { + let action; + const testActionType = 'TEST_TYPE'; + beforeEach(() => { + (relationEffects as any).initialActionMap[identifier] = testActionType; + (relationEffects as any).debounceMap[identifier] = new BehaviorSubject(testActionType); + }); + + it('should set the current value debounceMap to REMOVE_RELATIONSHIP but not change the value of the initialActionMap', () => { + action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--a-', { a: action }); + const expected = cold('--b-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + + expect((relationEffects as any).initialActionMap[identifier]).toBe(testActionType); + expect((relationEffects as any).debounceMap[identifier].value).toBe(action.type); + }); + }); + + describe('When the initialActionMap contains an REMOVE_RELATIONSHIP action', () => { + let action; + describe('When the last value in the debounceMap is also an REMOVE_RELATIONSHIP action', () => { + beforeEach(() => { + (relationEffects as any).initialActionMap[identifier] = RelationshipActionTypes.REMOVE_RELATIONSHIP; + spyOnOperator(operators, 'debounceTime').and.returnValue((v) => v); + spyOn((relationEffects as any), 'removeRelationship'); + }); + + it('should call removeRelationship on the effect', () => { + action = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--a-', { a: action }); + const expected = cold('--b-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + expect((relationEffects as any).removeRelationship).toHaveBeenCalledWith(leftItem, rightItem, relationshipType.leftwardType) + }); + }); + + describe('When the last value in the debounceMap is instead a ADD_RELATIONSHIP action', () => { + beforeEach(() => { + /** + * Change debounceTime to last so there's no need to fire a certain amount of actions in the debounce time frame + */ + spyOnOperator(operators, 'debounceTime').and.returnValue((v) => v.pipe(last())); + spyOn((relationEffects as any), 'addRelationship'); + spyOn((relationEffects as any), 'removeRelationship'); + }); + it('should not call addRelationship or removeRelationship on the effect', () => { + const actionb = new RemoveRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + const actiona = new AddRelationshipAction(leftItem, rightItem, relationshipType.leftwardType); + actions = hot('--ab-', { a: actiona, b: actionb }); + const expected = cold('--bb-', { b: undefined }); + expect(relationEffects.mapLastActions$).toBeObservable(expected); + expect((relationEffects as any).addRelationship).not.toHaveBeenCalled(); + expect((relationEffects as any).removeRelationship).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts new file mode 100644 index 0000000000..1880090d13 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts @@ -0,0 +1,134 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { debounceTime, map, mergeMap, take, tap } from 'rxjs/operators'; +import { BehaviorSubject } from 'rxjs'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { AddRelationshipAction, RelationshipAction, RelationshipActionTypes, RemoveRelationshipAction, UpdateRelationshipAction } from './relationship.actions'; +import { Item } from '../../../../../core/shared/item.model'; +import { hasNoValue, hasValue, hasValueOperator } from '../../../../empty.util'; +import { Relationship } from '../../../../../core/shared/item-relationships/relationship.model'; +import { RelationshipType } from '../../../../../core/shared/item-relationships/relationship-type.model'; +import { RelationshipTypeService } from '../../../../../core/data/relationship-type.service'; + +const DEBOUNCE_TIME = 5000; + +/** + * NGRX effects for RelationshipEffects + */ +@Injectable() +export class RelationshipEffects { + /** + * Map that keeps track of the latest RelationshipEffects for each relationship's composed identifier + */ + private debounceMap: { + [identifier: string]: BehaviorSubject + } = {}; + + private nameVariantUpdates: { + [identifier: string]: string + } = {}; + + private initialActionMap: { + [identifier: string]: string + } = {}; + + /** + * Effect that makes sure all last fired RelationshipActions' types are stored in the map of this service, with the object uuid as their key + */ + @Effect({ dispatch: false }) mapLastActions$ = this.actions$ + .pipe( + ofType(RelationshipActionTypes.ADD_RELATIONSHIP, RelationshipActionTypes.REMOVE_RELATIONSHIP), + map((action: RelationshipAction) => { + const { item1, item2, relationshipType } = action.payload; + const identifier: string = this.createIdentifier(item1, item2, relationshipType); + if (hasNoValue(this.debounceMap[identifier])) { + this.initialActionMap[identifier] = action.type; + this.debounceMap[identifier] = new BehaviorSubject(action.type); + this.debounceMap[identifier].pipe( + debounceTime(DEBOUNCE_TIME), + take(1) + ).subscribe( + (type) => { + if (this.initialActionMap[identifier] === type) { + if (type === RelationshipActionTypes.ADD_RELATIONSHIP) { + let nameVariant = (action as AddRelationshipAction).payload.nameVariant; + if (hasValue(this.nameVariantUpdates[identifier])) { + nameVariant = this.nameVariantUpdates[identifier]; + delete this.nameVariantUpdates[identifier]; + } + this.addRelationship(item1, item2, relationshipType, nameVariant) + } else { + this.removeRelationship(item1, item2, relationshipType); + } + } + delete this.debounceMap[identifier]; + delete this.initialActionMap[identifier]; + } + ) + } else { + this.debounceMap[identifier].next(action.type); + } + } + ) + ); + + /** + * Updates the namevariant in a relationship + * If the relationship is currently being added or removed, it will add the name variant to an update map so it will be sent with the next add request instead + * Otherwise the update is done immediately + */ + @Effect({ dispatch: false }) updateNameVariantsActions$ = this.actions$ + .pipe( + ofType(RelationshipActionTypes.UPDATE_RELATIONSHIP), + map((action: UpdateRelationshipAction) => { + const { item1, item2, relationshipType, nameVariant } = action.payload; + const identifier: string = this.createIdentifier(item1, item2, relationshipType); + const inProgress = hasValue(this.debounceMap[identifier]); + if (inProgress) { + this.nameVariantUpdates[identifier] = nameVariant; + } else { + this.relationshipService.updateNameVariant(item1, item2, relationshipType, nameVariant) + .pipe() + .subscribe(); + } + } + ) + ); + + constructor(private actions$: Actions, + private relationshipService: RelationshipService, + private relationshipTypeService: RelationshipTypeService, + ) { + } + + private createIdentifier(item1: Item, item2: Item, relationshipType: string): string { + return `${item1.uuid}-${item2.uuid}-${relationshipType}`; + } + + private addRelationship(item1: Item, item2: Item, relationshipType: string, nameVariant?: string) { + const type1: string = item1.firstMetadataValue('relationship.type'); + const type2: string = item2.firstMetadataValue('relationship.type'); + return this.relationshipTypeService.getRelationshipTypeByLabelAndTypes(relationshipType, type1, type2) + .pipe( + mergeMap((type: RelationshipType) => { + const isSwitched = type.rightwardType === relationshipType; + if (isSwitched) { + return this.relationshipService.addRelationship(type.id, item2, item1, nameVariant, undefined); + } else { + return this.relationshipService.addRelationship(type.id, item1, item2, undefined, nameVariant); + } + } + ) + ).pipe(take(1)) + .subscribe(); + } + + private removeRelationship(item1: Item, item2: Item, relationshipType: string) { + this.relationshipService.getRelationshipByItemsAndLabel(item1, item2, relationshipType).pipe( + take(1), + hasValueOperator(), + mergeMap((relationship: Relationship) => this.relationshipService.deleteRelationship(relationship.id, 'none')), + take(1) + ).subscribe(); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.html new file mode 100644 index 0000000000..4e2da1f12b --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.html @@ -0,0 +1,69 @@ +
+ +
+ + + + +
+
+
+
+ + + + +
+
+
+ + +
+ + + + +
+
+
+
+ + +
+
\ No newline at end of file diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.scss new file mode 100644 index 0000000000..4562a95080 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.scss @@ -0,0 +1,3 @@ +.position-absolute { + right: $spacer; +} \ No newline at end of file diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.spec.ts new file mode 100644 index 0000000000..4434684cbb --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.spec.ts @@ -0,0 +1,144 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { DsDynamicLookupRelationSearchTabComponent } from './dynamic-lookup-relation-search-tab.component'; +import { SearchService } from '../../../../../../core/shared/search/search.service'; +import { SelectableListService } from '../../../../../object-list/selectable-list/selectable-list.service'; +import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; +import { RouteService } from '../../../../../../core/services/route.service'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { VarDirective } from '../../../../../utils/var.directive'; +import { RelationshipOptions } from '../../../models/relationship-options.model'; +import { of as observableOf } from 'rxjs'; +import { PaginatedSearchOptions } from '../../../../../search/paginated-search-options.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../testing/utils'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; +import { ItemSearchResult } from '../../../../../object-collection/shared/item-search-result.model'; +import { Item } from '../../../../../../core/shared/item.model'; + +describe('DsDynamicLookupRelationSearchTabComponent', () => { + let component: DsDynamicLookupRelationSearchTabComponent; + let fixture: ComponentFixture; + let relationship; + let pSearchOptions; + let item1; + let item2; + let item3; + let item4; + let searchResult1; + let searchResult2; + let searchResult3; + let searchResult4; + let listID; + let selection$; + + let results; + let selectableListService; + + function init() { + relationship = { filter: 'filter', relationshipType: 'isAuthorOfPublication', nameVariants: true } as RelationshipOptions; + pSearchOptions = new PaginatedSearchOptions({}); + item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); + item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); + item3 = Object.assign(new Item(), { uuid: 'c3bcbff5-ec0c-4831-8e4c-94b9c933ccac' }); + item4 = Object.assign(new Item(), { uuid: 'f96a385e-de10-45b2-be66-7f10bf52f765' }); + searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); + searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); + searchResult3 = Object.assign(new ItemSearchResult(), { indexableObject: item3 }); + searchResult4 = Object.assign(new ItemSearchResult(), { indexableObject: item4 }); + listID = '6b0c8221-fcb4-47a8-b483-ca32363fffb3'; + selection$ = observableOf([searchResult1, searchResult2]); + + results = new PaginatedList(undefined, [searchResult1, searchResult2, searchResult3]); + selectableListService = jasmine.createSpyObj('selectableListService', ['deselect', 'select', 'deselectAll']); + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [DsDynamicLookupRelationSearchTabComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: SearchService, useValue: { search: () => createSuccessfulRemoteDataObject$(results) } }, + { + provide: SelectableListService, useValue: selectableListService + }, + { + provide: SearchConfigurationService, useValue: { + paginatedSearchOptions: observableOf(pSearchOptions) + } + }, + { + provide: RouteService, useValue: { + setParameter: () => { + // do nothing + } + } + }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsDynamicLookupRelationSearchTabComponent); + component = fixture.componentInstance; + component.relationship = relationship; + component.selection$ = selection$; + component.listId = listID; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('selectPage', () => { + beforeEach(() => { + spyOn(component.selectObject, 'emit'); + component.selectPage([searchResult1, searchResult2, searchResult4]); + }); + + it('should emit the page filtered from already selected objects and call select on the service for all objects', () => { + expect(component.selectObject.emit).toHaveBeenCalledWith(searchResult4); + expect(selectableListService.select).toHaveBeenCalledWith(listID, [searchResult1, searchResult2, searchResult4]); + }); + }); + + describe('deselectPage', () => { + beforeEach(() => { + spyOn(component.deselectObject, 'emit'); + component.deselectPage([searchResult1, searchResult2, searchResult3]); + }); + + it('should emit the page filtered from not yet selected objects and call select on the service for all objects', () => { + expect(component.deselectObject.emit).toHaveBeenCalledWith(searchResult1, searchResult2); + expect(selectableListService.deselect).toHaveBeenCalledWith(listID, [searchResult1, searchResult2, searchResult3]); + }); + }); + + describe('selectAll', () => { + beforeEach(() => { + spyOn(component.selectObject, 'emit'); + component.selectAll(); + }); + + it('should emit the page filtered from already selected objects and call select on the service for all objects', () => { + expect(component.selectObject.emit).toHaveBeenCalledWith(searchResult3); + expect(selectableListService.select).toHaveBeenCalledWith(listID, [searchResult1, searchResult2, searchResult3]); + }); + }); + + describe('deselectAll', () => { + beforeEach(() => { + spyOn(component.deselectObject, 'emit'); + component.deselectAll(); + }); + + it('should emit the page filtered from not yet selected objects and call select on the service for all objects', () => { + expect(component.deselectObject.emit).toHaveBeenCalledWith(searchResult1, searchResult2); + expect(selectableListService.deselectAll).toHaveBeenCalledWith(listID); + }); + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts new file mode 100644 index 0000000000..9c00d64953 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts @@ -0,0 +1,181 @@ +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { SEARCH_CONFIG_SERVICE } from '../../../../../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; +import { Item } from '../../../../../../core/shared/item.model'; +import { PaginatedSearchOptions } from '../../../../../search/paginated-search-options.model'; +import { SearchResult } from '../../../../../search/search-result.model'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; +import { RemoteData } from '../../../../../../core/data/remote-data'; +import { Observable, ReplaySubject } from 'rxjs'; +import { RelationshipOptions } from '../../../models/relationship-options.model'; +import { PaginationComponentOptions } from '../../../../../pagination/pagination-component-options.model'; +import { ListableObject } from '../../../../../object-collection/shared/listable-object.model'; +import { SearchService } from '../../../../../../core/shared/search/search.service'; +import { Router } from '@angular/router'; +import { SelectableListService } from '../../../../../object-list/selectable-list/selectable-list.service'; +import { hasValue, isNotEmpty } from '../../../../../empty.util'; +import { concat, map, multicast, switchMap, take, takeWhile, tap } from 'rxjs/operators'; +import { DSpaceObject } from '../../../../../../core/shared/dspace-object.model'; +import { getSucceededRemoteData } from '../../../../../../core/shared/operators'; +import { RouteService } from '../../../../../../core/services/route.service'; +import { CollectionElementLinkType } from '../../../../../object-collection/collection-element-link.type'; +import { Context } from '../../../../../../core/shared/context.model'; + +@Component({ + selector: 'ds-dynamic-lookup-relation-search-tab', + styleUrls: ['./dynamic-lookup-relation-search-tab.component.scss'], + templateUrl: './dynamic-lookup-relation-search-tab.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) + +/** + * Tab for inside the lookup model that represents the items that can be used as a relationship in this submission + */ +export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDestroy { + @Input() relationship: RelationshipOptions; + @Input() listId: string; + @Input() repeatable: boolean; + @Input() selection$: Observable; + @Input() context: Context; + + @Output() deselectObject: EventEmitter = new EventEmitter(); + @Output() selectObject: EventEmitter = new EventEmitter(); + resultsRD$: Observable>>>; + searchConfig: PaginatedSearchOptions; + allSelected: boolean; + someSelected$: Observable; + selectAllLoading: boolean; + subscription; + initialPagination = Object.assign(new PaginationComponentOptions(), { + id: 'submission-relation-list', + pageSize: 5 + }); + linkTypes = CollectionElementLinkType; + + constructor( + private searchService: SearchService, + private router: Router, + private selectableListService: SelectableListService, + private searchConfigService: SearchConfigurationService, + private routeService: RouteService, + ) { + } + + /** + * Sets up the pagination and fixed query parameters + */ + ngOnInit(): void { + this.resetRoute(); + this.routeService.setParameter('fixedFilterQuery', this.relationship.filter); + this.routeService.setParameter('configuration', this.relationship.searchConfiguration); + + this.someSelected$ = this.selection$.pipe(map((selection) => isNotEmpty(selection))); + this.resultsRD$ = this.searchConfigService.paginatedSearchOptions.pipe( + map((options) => { + return Object.assign(new PaginatedSearchOptions({}), options, { fixedFilter: this.relationship.filter, configuration: this.relationship.searchConfiguration }) + }), + switchMap((options) => { + this.searchConfig = options; + return this.searchService.search(options).pipe( + /* Make sure to only listen to the first x results, until loading is finished */ + /* TODO: in Rxjs 6.4.0 and up, we can replace this with takeWhile(predicate, true) - see https://stackoverflow.com/a/44644237 */ + multicast( + () => new ReplaySubject(1), + (subject) => subject.pipe( + takeWhile((rd: RemoteData>>) => rd.isLoading), + concat(subject.pipe(take(1))) + ) + ) as any + ) + }) + ); + } + + /** + * Method to reset the route when the window is opened to make sure no strange pagination issues appears + */ + resetRoute() { + this.router.navigate([], { + queryParams: Object.assign({}, { page: 1, pageSize: this.initialPagination.pageSize }), + }); + } + + /** + * Selects a page in the store + * @param page The page to select + */ + selectPage(page: Array>) { + this.selection$ + .pipe(take(1)) + .subscribe((selection: Array>) => { + const filteredPage = page.filter((pageItem) => selection.findIndex((selected) => selected.equals(pageItem)) < 0); + this.selectObject.emit(...filteredPage); + }); + this.selectableListService.select(this.listId, page); + } + + /** + * Deselects a page in the store + * @param page the page to deselect + */ + deselectPage(page: Array>) { + this.allSelected = false; + this.selection$ + .pipe(take(1)) + .subscribe((selection: Array>) => { + const filteredPage = page.filter((pageItem) => selection.findIndex((selected) => selected.equals(pageItem)) >= 0); + this.deselectObject.emit(...filteredPage); + }); + this.selectableListService.deselect(this.listId, page); + } + + /** + * Select all items that were found using the current search query + */ + selectAll() { + this.allSelected = true; + this.selectAllLoading = true; + const fullPagination = Object.assign(new PaginationComponentOptions(), { + currentPage: 1, + pageSize: 9999 + }); + const fullSearchConfig = Object.assign(this.searchConfig, { pagination: fullPagination }); + const results$ = this.searchService.search(fullSearchConfig) as Observable>>>; + results$.pipe( + getSucceededRemoteData(), + map((resultsRD) => resultsRD.payload.page), + tap(() => this.selectAllLoading = false), + ).subscribe((results) => { + this.selection$ + .pipe(take(1)) + .subscribe((selection: Array>) => { + const filteredResults = results.filter((pageItem) => selection.findIndex((selected) => selected.equals(pageItem)) < 0); + this.selectObject.emit(...filteredResults); + }); + this.selectableListService.select(this.listId, results); + } + ); + } + + /** + * Deselect all items + */ + deselectAll() { + this.allSelected = false; + this.selection$ + .pipe(take(1)) + .subscribe((selection: Array>) => this.deselectObject.emit(...selection)); + this.selectableListService.deselectAll(this.listId); + } + + ngOnDestroy(): void { + if (hasValue(this.subscription)) { + this.subscription.unsubscribe(); + } + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.html new file mode 100644 index 0000000000..46ee1727fe --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.html @@ -0,0 +1,23 @@ +
+
+

{{ 'submission.sections.describe.relationship-lookup.selection-tab.settings' | translate}}

+ +
+
+
+ {{'submission.sections.describe.relationship-lookup.selection-tab.no-selection' | translate}} +
+
+

{{ 'submission.sections.describe.relationship-lookup.selection-tab.title.' + label | translate}}

+ +
+
+
\ No newline at end of file diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.spec.ts new file mode 100644 index 0000000000..203a4df0b0 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.spec.ts @@ -0,0 +1,97 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { VarDirective } from '../../../../../utils/var.directive'; +import { Observable, of as observableOf } from 'rxjs'; +import { PaginatedSearchOptions } from '../../../../../search/paginated-search-options.model'; +import { ItemSearchResult } from '../../../../../object-collection/shared/item-search-result.model'; +import { Item } from '../../../../../../core/shared/item.model'; +import { DsDynamicLookupRelationSelectionTabComponent } from './dynamic-lookup-relation-selection-tab.component'; +import { PaginationComponentOptions } from '../../../../../pagination/pagination-component-options.model'; +import { Router } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { RemoteData } from '../../../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; +import { ListableObject } from '../../../../../object-collection/shared/listable-object.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../testing/utils'; + +describe('DsDynamicLookupRelationSelectionTabComponent', () => { + let component: DsDynamicLookupRelationSelectionTabComponent; + let fixture: ComponentFixture; + let pSearchOptions = new PaginatedSearchOptions({ pagination: new PaginationComponentOptions() }); + let item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); + let item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); + let searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); + let searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); + let listID = '6b0c8221-fcb4-47a8-b483-ca32363fffb3'; + let selection$; + let selectionRD$; + let router; + + function init() { + pSearchOptions = new PaginatedSearchOptions({ pagination: new PaginationComponentOptions() }); + item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); + item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); + searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); + searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); + listID = '6b0c8221-fcb4-47a8-b483-ca32363fffb3'; + selection$ = observableOf([searchResult1, searchResult2]); + selectionRD$ = createSelection([searchResult1, searchResult2]); + router = jasmine.createSpyObj('router', ['navigate']) + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [DsDynamicLookupRelationSelectionTabComponent, VarDirective], + imports: [TranslateModule.forRoot()], + providers: [ + { + provide: SearchConfigurationService, useValue: { + paginatedSearchOptions: observableOf(pSearchOptions) + }, + }, + { + provide: Router, useValue: router + } + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsDynamicLookupRelationSelectionTabComponent); + component = fixture.componentInstance; + component.selection$ = selection$; + component.listId = listID; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call navigate on the router when is called resetRoute', () => { + component.resetRoute(); + expect(router.navigate).toHaveBeenCalled(); + }); + + it('should call navigate on the router when is called resetRoute', () => { + component.selectionRD$ = createSelection([]); + fixture.detectChanges(); + const colComponent = fixture.debugElement.query(By.css('ds-viewable-collection')); + expect(colComponent).toBe(null); + }); + + it('should call navigate on the router when is called resetRoute', () => { + component.selectionRD$ = selectionRD$; + const colComponent = fixture.debugElement.query(By.css('ds-viewable-collection')); + expect(colComponent).not.toBe(null); + }); +}); + +function createSelection(content: ListableObject[]): Observable>> { + return createSuccessfulRemoteDataObject$(new PaginatedList(undefined, content)); +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.ts new file mode 100644 index 0000000000..8aa3dc3828 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component.ts @@ -0,0 +1,87 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { SEARCH_CONFIG_SERVICE } from '../../../../../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; +import { Observable } from 'rxjs'; +import { ListableObject } from '../../../../../object-collection/shared/listable-object.model'; +import { RemoteData } from '../../../../../../core/data/remote-data'; +import { map, switchMap, take } from 'rxjs/operators'; +import { createSuccessfulRemoteDataObject } from '../../../../../testing/utils'; +import { PaginationComponentOptions } from '../../../../../pagination/pagination-component-options.model'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; +import { Router } from '@angular/router'; +import { PaginatedSearchOptions } from '../../../../../search/paginated-search-options.model'; +import { PageInfo } from '../../../../../../core/shared/page-info.model'; +import { Context } from '../../../../../../core/shared/context.model'; + +@Component({ + selector: 'ds-dynamic-lookup-relation-selection-tab', + styleUrls: ['./dynamic-lookup-relation-selection-tab.component.scss'], + templateUrl: './dynamic-lookup-relation-selection-tab.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) + +/** + * Tab for inside the lookup model that represents the currently selected relationships + */ +export class DsDynamicLookupRelationSelectionTabComponent { + @Input() label: string; + @Input() listId: string; + @Input() repeatable: boolean; + @Input() selection$: Observable; + @Input() selectionRD$: Observable>>; + @Input() context: Context; + @Output() deselectObject: EventEmitter = new EventEmitter(); + @Output() selectObject: EventEmitter = new EventEmitter(); + + initialPagination = Object.assign(new PaginationComponentOptions(), { + id: 'submission-relation-list', + pageSize: 5 + }); + + constructor(private router: Router, + private searchConfigService: SearchConfigurationService) { + } + + /** + * Set up the selection and pagination on load + */ + ngOnInit() { + this.resetRoute(); + this.selectionRD$ = this.searchConfigService.paginatedSearchOptions + .pipe( + map((options: PaginatedSearchOptions) => options.pagination), + switchMap((pagination: PaginationComponentOptions) => { + return this.selection$.pipe( + take(1), + map((selected) => { + const offset = (pagination.currentPage - 1) * pagination.pageSize; + const end = (offset + pagination.pageSize) > selected.length ? selected.length : offset + pagination.pageSize; + const selection = selected.slice(offset, end); + const pageInfo = new PageInfo( + { + elementsPerPage: pagination.pageSize, + totalElements: selected.length, + currentPage: pagination.currentPage, + totalPages: Math.ceil(selected.length / pagination.pageSize) + }); + return createSuccessfulRemoteDataObject(new PaginatedList(pageInfo, selection)); + }) + ); + }) + ) + } + + /** + * Method to reset the route when the window is opened to make sure no strange pagination issues appears + */ + resetRoute() { + this.router.navigate([], { + queryParams: Object.assign({}, { page: 1, pageSize: this.initialPagination.pageSize }), + }); + } +} diff --git a/src/app/shared/form/builder/form-builder.service.spec.ts b/src/app/shared/form/builder/form-builder.service.spec.ts index 58a1696a92..ea0957f689 100644 --- a/src/app/shared/form/builder/form-builder.service.spec.ts +++ b/src/app/shared/form/builder/form-builder.service.spec.ts @@ -56,6 +56,8 @@ describe('FormBuilderService test suite', () => { let testFormConfiguration: SubmissionFormsModel; let service: FormBuilderService; + const submissionId = '1234'; + function testValidator() { return {testValidator: {valid: true}}; } @@ -193,17 +195,18 @@ describe('FormBuilderService test suite', () => { new DynamicColorPickerModel({id: 'testColorPicker'}), - new DynamicTypeaheadModel({id: 'testTypeahead'}), + new DynamicTypeaheadModel({id: 'testTypeahead', repeatable: false, metadataFields: [], submissionId: '1234'}), - new DynamicScrollableDropdownModel({id: 'testScrollableDropdown', authorityOptions: authorityOptions}), + new DynamicScrollableDropdownModel({id: 'testScrollableDropdown', authorityOptions: authorityOptions, repeatable: false, metadataFields: [], submissionId: '1234'}), - new DynamicTagModel({id: 'testTag'}), + new DynamicTagModel({id: 'testTag', repeatable: false, metadataFields: [], submissionId: '1234'}), new DynamicListCheckboxGroupModel({id: 'testCheckboxList', authorityOptions: authorityOptions, repeatable: true}), new DynamicListRadioGroupModel({id: 'testRadioList', authorityOptions: authorityOptions, repeatable: false}), new DynamicRelationGroupModel({ + submissionId, id: 'testRelationGroup', formConfiguration: [{ fields: [{ @@ -239,16 +242,18 @@ describe('FormBuilderService test suite', () => { name: 'testRelationGroup', relationFields: [], scopeUUID: '', - submissionScope: '' + submissionScope: '', + repeatable: false, + metadataFields: [] }), new DynamicDsDatePickerModel({id: 'testDate'}), - new DynamicLookupModel({id: 'testLookup'}), + new DynamicLookupModel({id: 'testLookup', repeatable: false, metadataFields: [], submissionId: '1234'}), - new DynamicLookupNameModel({id: 'testLookupName'}), + new DynamicLookupNameModel({id: 'testLookupName', repeatable: false, metadataFields: [], submissionId: '1234'}), - new DynamicQualdropModel({id: 'testCombobox', readOnly: false}), + new DynamicQualdropModel({id: 'testCombobox', readOnly: false, required: false}), new DynamicRowArrayModel( { @@ -260,6 +265,7 @@ describe('FormBuilderService test suite', () => { new DynamicInputModel({id: 'testFormRowArrayGroupInput'}) ]; }, + required: false } ), ]; @@ -406,7 +412,7 @@ describe('FormBuilderService test suite', () => { }); it('should create an array of form models', () => { - const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + const formModel = service.modelFromConfiguration(submissionId, testFormConfiguration, 'testScopeUUID'); expect(formModel[0] instanceof DynamicRowGroupModel).toBe(true); expect((formModel[0] as DynamicRowGroupModel).group.length).toBe(3); @@ -427,7 +433,7 @@ describe('FormBuilderService test suite', () => { }); it('should return form\'s fields value from form model', () => { - const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + const formModel = service.modelFromConfiguration(submissionId, testFormConfiguration, 'testScopeUUID'); let value = {} as any; expect(service.getValueFromModel(formModel)).toEqual(value); @@ -448,7 +454,7 @@ describe('FormBuilderService test suite', () => { }); it('should clear all form\'s fields value', () => { - const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + const formModel = service.modelFromConfiguration(submissionId, testFormConfiguration, 'testScopeUUID'); const value = {} as any; ((formModel[0] as DynamicRowGroupModel).get(1) as DsDynamicInputModel).valueUpdates.next('test'); @@ -460,7 +466,7 @@ describe('FormBuilderService test suite', () => { }); it('should return true when model has a custom group model as parent', () => { - const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + const formModel = service.modelFromConfiguration(submissionId, testFormConfiguration, 'testScopeUUID'); let model = service.findById('dc_identifier_QUALDROP_VALUE', formModel); let modelParent = service.findById('dc_identifier_QUALDROP_GROUP', formModel); model.parent = modelParent; @@ -489,7 +495,7 @@ describe('FormBuilderService test suite', () => { }); it('should return true when model value is a map', () => { - const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + const formModel = service.modelFromConfiguration(submissionId, testFormConfiguration, 'testScopeUUID'); const model = service.findById('dc_identifier_QUALDROP_VALUE', formModel); const modelParent = service.findById('dc_identifier_QUALDROP_GROUP', formModel); model.parent = modelParent; @@ -498,7 +504,7 @@ describe('FormBuilderService test suite', () => { }); it('should return true when model is a Qualdrop Group', () => { - const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + const formModel = service.modelFromConfiguration(submissionId, testFormConfiguration, 'testScopeUUID'); let model = service.findById('dc_identifier_QUALDROP_GROUP', formModel); expect(service.isQualdropGroup(model)).toBe(true); @@ -509,7 +515,7 @@ describe('FormBuilderService test suite', () => { }); it('should return true when model is a Custom or List Group', () => { - const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + const formModel = service.modelFromConfiguration(submissionId, testFormConfiguration, 'testScopeUUID'); let model = service.findById('dc_identifier_QUALDROP_GROUP', formModel); expect(service.isCustomOrListGroup(model)).toBe(true); @@ -528,7 +534,7 @@ describe('FormBuilderService test suite', () => { }); it('should return true when model is a Custom Group', () => { - const formModel = service.modelFromConfiguration(testFormConfiguration, 'testScopeUUID'); + const formModel = service.modelFromConfiguration(submissionId, testFormConfiguration, 'testScopeUUID'); let model = service.findById('dc_identifier_QUALDROP_GROUP', formModel); expect(service.isCustomGroup(model)).toBe(true); diff --git a/src/app/shared/form/builder/form-builder.service.ts b/src/app/shared/form/builder/form-builder.service.ts index 21e702aabb..dcc9403d9b 100644 --- a/src/app/shared/form/builder/form-builder.service.ts +++ b/src/app/shared/form/builder/form-builder.service.ts @@ -11,6 +11,7 @@ import { DynamicFormControlModel, DynamicFormGroupModel, DynamicFormService, + DynamicFormValidationService, DynamicPathable, JSONUtils, } from '@ng-dynamic-forms/core'; @@ -21,10 +22,7 @@ import { DynamicQualdropModel } from './ds-dynamic-form-ui/models/ds-dynamic-qua import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; import { DYNAMIC_FORM_CONTROL_TYPE_TAG } from './ds-dynamic-form-ui/models/tag/dynamic-tag.model'; import { RowParser } from './parsers/row-parser'; -import { - DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP, - DynamicRelationGroupModel -} from './ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; +import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP, DynamicRelationGroupModel } from './ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; import { DynamicRowArrayModel } from './ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; import { DsDynamicInputModel } from './ds-dynamic-form-ui/models/ds-dynamic-input.model'; import { FormFieldMetadataValueObject } from './models/form-field-metadata-value.model'; @@ -33,6 +31,13 @@ import { isNgbDateStruct } from '../../date.util'; @Injectable() export class FormBuilderService extends DynamicFormService { + constructor( + validationService: DynamicFormValidationService, + protected rowParser: RowParser + ) { + super(validationService); + } + findById(id: string, groupModel: DynamicFormControlModel[], arrayIndex = null): DynamicFormControlModel | null { let result = null; @@ -198,13 +203,13 @@ export class FormBuilderService extends DynamicFormService { return result; } - modelFromConfiguration(json: string | SubmissionFormsModel, scopeUUID: string, initFormValues: any = {}, submissionScope?: string, readOnly = false): DynamicFormControlModel[] | never { + modelFromConfiguration(submissionId: string, json: string | SubmissionFormsModel, scopeUUID: string, sectionData: any = {}, submissionScope?: string, readOnly = false): DynamicFormControlModel[] | never { let rows: DynamicFormControlModel[] = []; const rawData = typeof json === 'string' ? JSON.parse(json, JSONUtils.parseReviver) : json; if (rawData.rows && !isEmpty(rawData.rows)) { rawData.rows.forEach((currentRow) => { - const rowParsed = new RowParser(currentRow, scopeUUID, initFormValues, submissionScope, readOnly).parse(); + const rowParsed = this.rowParser.parse(submissionId, currentRow, scopeUUID, sectionData, submissionScope, readOnly); if (isNotNull(rowParsed)) { if (Array.isArray(rowParsed)) { rows = rows.concat(rowParsed); diff --git a/src/app/shared/form/builder/models/form-field.model.ts b/src/app/shared/form/builder/models/form-field.model.ts index 39f2d6b9f8..718b3f4f0d 100644 --- a/src/app/shared/form/builder/models/form-field.model.ts +++ b/src/app/shared/form/builder/models/form-field.model.ts @@ -1,6 +1,7 @@ import { autoserialize } from 'cerialize'; import { LanguageCode } from './form-field-language-value.model'; import { FormFieldMetadataValueObject } from './form-field-metadata-value.model'; +import { RelationshipOptions } from './relationship-options.model'; import { FormRowModel } from '../../../../core/config/models/config-submission-form.model'; export class FormFieldModel { @@ -32,6 +33,9 @@ export class FormFieldModel { @autoserialize selectableMetadata: FormFieldMetadataValueObject[]; + @autoserialize + selectableRelationship: RelationshipOptions; + @autoserialize rows: FormRowModel[]; diff --git a/src/app/shared/form/builder/models/relationship-options.model.ts b/src/app/shared/form/builder/models/relationship-options.model.ts new file mode 100644 index 0000000000..f1d3d0ae7a --- /dev/null +++ b/src/app/shared/form/builder/models/relationship-options.model.ts @@ -0,0 +1,15 @@ +const RELATION_METADATA_PREFIX = 'relation.' + +/** + * The submission options for fields that can represent relationships + */ +export class RelationshipOptions { + relationshipType: string; + filter: string; + searchConfiguration: string; + nameVariants: boolean; + + get metadataField() { + return RELATION_METADATA_PREFIX + this.relationshipType + } +} diff --git a/src/app/shared/form/builder/parsers/concat-field-parser.ts b/src/app/shared/form/builder/parsers/concat-field-parser.ts index 6323905555..33a92c726d 100644 --- a/src/app/shared/form/builder/parsers/concat-field-parser.ts +++ b/src/app/shared/form/builder/parsers/concat-field-parser.ts @@ -1,7 +1,11 @@ -import { FieldParser } from './field-parser'; +import { Inject } from '@angular/core'; import { FormFieldModel } from '../models/form-field.model'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; -import { DynamicFormControlLayout, DynamicInputModel, DynamicInputModelConfig } from '@ng-dynamic-forms/core'; +import { + DynamicFormControlLayout, + DynamicInputModel, + DynamicInputModelConfig +} from '@ng-dynamic-forms/core'; import { CONCAT_FIRST_INPUT_SUFFIX, CONCAT_GROUP_SUFFIX, @@ -11,16 +15,25 @@ import { } from '../ds-dynamic-form-ui/models/ds-dynamic-concat.model'; import { isNotEmpty } from '../../../empty.util'; import { ParserOptions } from './parser-options'; +import { + CONFIG_DATA, + FieldParser, + INIT_FORM_VALUES, + PARSER_OPTIONS, + SUBMISSION_ID +} from './field-parser'; export class ConcatFieldParser extends FieldParser { - constructor(protected configData: FormFieldModel, - protected initFormValues, - protected parserOptions: ParserOptions, - protected separator: string, - protected firstPlaceholder: string = null, - protected secondPlaceholder: string = null) { - super(configData, initFormValues, parserOptions); + constructor( + @Inject(SUBMISSION_ID) submissionId: string, + @Inject(CONFIG_DATA) configData: FormFieldModel, + @Inject(INIT_FORM_VALUES) initFormValues, + @Inject(PARSER_OPTIONS) parserOptions: ParserOptions, + protected separator: string, + protected firstPlaceholder: string = null, + protected secondPlaceholder: string = null) { + super(submissionId, configData, initFormValues, parserOptions); this.separator = separator; this.firstPlaceholder = firstPlaceholder; @@ -40,16 +53,17 @@ export class ConcatFieldParser extends FieldParser { }; const groupId = id.replace(/\./g, '_') + CONCAT_GROUP_SUFFIX; - const concatGroup: DynamicConcatModelConfig = this.initModel(groupId, false, false); + const concatGroup: DynamicConcatModelConfig = this.initModel(groupId, label, false); concatGroup.group = []; concatGroup.separator = this.separator; - const input1ModelConfig: DynamicInputModelConfig = this.initModel(id + CONCAT_FIRST_INPUT_SUFFIX, label, false, false); - const input2ModelConfig: DynamicInputModelConfig = this.initModel(id + CONCAT_SECOND_INPUT_SUFFIX, label, true, false); + const input1ModelConfig: DynamicInputModelConfig = this.initModel(id + CONCAT_FIRST_INPUT_SUFFIX, false, false); + const input2ModelConfig: DynamicInputModelConfig = this.initModel(id + CONCAT_SECOND_INPUT_SUFFIX, false, false); input2ModelConfig.hint = ' '; if (this.configData.mandatory) { + concatGroup.required = true; input1ModelConfig.required = true; } diff --git a/src/app/shared/form/builder/parsers/date-field-parser.spec.ts b/src/app/shared/form/builder/parsers/date-field-parser.spec.ts index bbcfa60621..efa4f3cdb5 100644 --- a/src/app/shared/form/builder/parsers/date-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/date-field-parser.spec.ts @@ -1,6 +1,4 @@ import { FormFieldModel } from '../models/form-field.model'; -import { DynamicConcatModel } from '../ds-dynamic-form-ui/models/ds-dynamic-concat.model'; -import { SeriesFieldParser } from './series-field-parser'; import { DateFieldParser } from './date-field-parser'; import { DynamicDsDatePickerModel } from '../ds-dynamic-form-ui/models/date-picker/date-picker.model'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; @@ -10,6 +8,7 @@ describe('DateFieldParser test suite', () => { let field: FormFieldModel; let initFormValues: any = {}; + const submissionId = '1234'; const parserOptions: ParserOptions = { readOnly: false, submissionScope: null, @@ -37,13 +36,13 @@ describe('DateFieldParser test suite', () => { }); it('should init parser properly', () => { - const parser = new DateFieldParser(field, initFormValues, parserOptions); + const parser = new DateFieldParser(submissionId, field, initFormValues, parserOptions); expect(parser instanceof DateFieldParser).toBe(true); }); it('should return a DynamicDsDatePickerModel object when repeatable option is false', () => { - const parser = new DateFieldParser(field, initFormValues, parserOptions); + const parser = new DateFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -56,7 +55,7 @@ describe('DateFieldParser test suite', () => { }; const expectedValue = '1983-11-18'; - const parser = new DateFieldParser(field, initFormValues, parserOptions); + const parser = new DateFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); diff --git a/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts b/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts new file mode 100644 index 0000000000..7dce05f18d --- /dev/null +++ b/src/app/shared/form/builder/parsers/disabled-field-parser.spec.ts @@ -0,0 +1,66 @@ +import { FormFieldModel } from '../models/form-field.model'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { ParserOptions } from './parser-options'; +import { DisabledFieldParser } from './disabled-field-parser'; +import { DynamicDisabledModel } from '../ds-dynamic-form-ui/models/disabled/dynamic-disabled.model'; + +describe('DisabledFieldParser test suite', () => { + let field: FormFieldModel; + let initFormValues: any = {}; + + const submissionId = '1234'; + const parserOptions: ParserOptions = { + readOnly: false, + submissionScope: null, + authorityUuid: null + }; + + beforeEach(() => { + field = { + input: { + type: '' + }, + label: 'Description', + mandatory: 'false', + repeatable: false, + hints: 'Enter a description.', + selectableMetadata: [ + { + metadata: 'description' + } + ], + languageCodes: [] + } as FormFieldModel; + + }); + + it('should init parser properly', () => { + const parser = new DisabledFieldParser(submissionId, field, initFormValues, parserOptions); + + expect(parser instanceof DisabledFieldParser).toBe(true); + }); + + it('should return a DynamicDisabledModel object when repeatable option is false', () => { + const parser = new DisabledFieldParser(submissionId, field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + + expect(fieldModel instanceof DynamicDisabledModel).toBe(true); + }); + + it('should set init value properly', () => { + initFormValues = { + description: [ + new FormFieldMetadataValueObject('test description'), + ], + }; + const expectedValue ='test description'; + + const parser = new DisabledFieldParser(submissionId, field, initFormValues, parserOptions); + + const fieldModel = parser.parse(); + console.log(fieldModel); + expect(fieldModel.value).toEqual(expectedValue); + }); + +}); diff --git a/src/app/shared/form/builder/parsers/disabled-field-parser.ts b/src/app/shared/form/builder/parsers/disabled-field-parser.ts new file mode 100644 index 0000000000..db3e4ac8b9 --- /dev/null +++ b/src/app/shared/form/builder/parsers/disabled-field-parser.ts @@ -0,0 +1,16 @@ +import { FieldParser } from './field-parser'; +import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { DsDynamicDisabledModelConfig, DynamicDisabledModel } from '../ds-dynamic-form-ui/models/disabled/dynamic-disabled.model'; + +/** + * Field parser for disabled fields + */ +export class DisabledFieldParser extends FieldParser { + + public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { + console.log(fieldValue); + const emptyModelConfig: DsDynamicDisabledModelConfig = this.initModel(null, label); + this.setValues(emptyModelConfig, fieldValue); + return new DynamicDisabledModel(emptyModelConfig) + } +} diff --git a/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts b/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts index 5dfdcfa5ce..8dbd68e05a 100644 --- a/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/dropdown-field-parser.spec.ts @@ -6,6 +6,7 @@ import { ParserOptions } from './parser-options'; describe('DropdownFieldParser test suite', () => { let field: FormFieldModel; + const submissionId = '1234'; const initFormValues = {}; const parserOptions: ParserOptions = { readOnly: false, @@ -35,13 +36,13 @@ describe('DropdownFieldParser test suite', () => { }); it('should init parser properly', () => { - const parser = new DropdownFieldParser(field, initFormValues, parserOptions); + const parser = new DropdownFieldParser(submissionId, field, initFormValues, parserOptions); expect(parser instanceof DropdownFieldParser).toBe(true); }); it('should return a DynamicScrollableDropdownModel object when repeatable option is false', () => { - const parser = new DropdownFieldParser(field, initFormValues, parserOptions); + const parser = new DropdownFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -50,7 +51,7 @@ describe('DropdownFieldParser test suite', () => { it('should throw when authority is not passed', () => { field.selectableMetadata[0].authority = null; - const parser = new DropdownFieldParser(field, initFormValues, parserOptions); + const parser = new DropdownFieldParser(submissionId, field, initFormValues, parserOptions); expect(() => parser.parse()) .toThrow(); diff --git a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts index 1623829b15..4816a2a073 100644 --- a/src/app/shared/form/builder/parsers/dropdown-field-parser.ts +++ b/src/app/shared/form/builder/parsers/dropdown-field-parser.ts @@ -1,4 +1,12 @@ -import { FieldParser } from './field-parser'; +import { Inject } from '@angular/core'; +import { FormFieldModel } from '../models/form-field.model'; +import { + CONFIG_DATA, + FieldParser, + INIT_FORM_VALUES, + PARSER_OPTIONS, + SUBMISSION_ID +} from './field-parser'; import { DynamicFormControlLayout, } from '@ng-dynamic-forms/core'; import { DynamicScrollableDropdownModel, @@ -6,9 +14,19 @@ import { } from '../ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; import { isNotEmpty } from '../../../empty.util'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; +import { ParserOptions } from './parser-options'; export class DropdownFieldParser extends FieldParser { + constructor( + @Inject(SUBMISSION_ID) submissionId: string, + @Inject(CONFIG_DATA) configData: FormFieldModel, + @Inject(INIT_FORM_VALUES) initFormValues, + @Inject(PARSER_OPTIONS) parserOptions: ParserOptions + ) { + super(submissionId, configData, initFormValues, parserOptions) + } + public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { const dropdownModelConfig: DynamicScrollableDropdownModelConfig = this.initModel(null, label); let layout: DynamicFormControlLayout; diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index dd37a45fba..f7bf12353c 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -1,4 +1,5 @@ -import { hasValue, isNotEmpty, isNotNull, isNotUndefined } from '../../../empty.util'; +import { Inject, InjectionToken } from '@angular/core'; +import { hasValue, isNotEmpty, isNotNull, isNotUndefined, isEmpty } from '../../../empty.util'; import { FormFieldModel } from '../models/form-field.model'; import { uniqueId } from 'lodash'; @@ -12,12 +13,23 @@ import { DynamicFormControlLayout } from '@ng-dynamic-forms/core'; import { setLayout } from './parser.utils'; import { AuthorityOptions } from '../../../../core/integration/models/authority-options.model'; import { ParserOptions } from './parser-options'; +import { RelationshipOptions } from '../models/relationship-options.model'; + +export const SUBMISSION_ID: InjectionToken = new InjectionToken('submissionId'); +export const CONFIG_DATA: InjectionToken = new InjectionToken('configData'); +export const INIT_FORM_VALUES: InjectionToken = new InjectionToken('initFormValues'); +export const PARSER_OPTIONS: InjectionToken = new InjectionToken('parserOptions'); export abstract class FieldParser { protected fieldId: string; - constructor(protected configData: FormFieldModel, protected initFormValues, protected parserOptions: ParserOptions) { + constructor( + @Inject(SUBMISSION_ID) protected submissionId: string, + @Inject(CONFIG_DATA) protected configData: FormFieldModel, + @Inject(INIT_FORM_VALUES) protected initFormValues: any, + @Inject(PARSER_OPTIONS) protected parserOptions: ParserOptions + ) { } public abstract modelFactory(fieldValue?: FormFieldMetadataValueObject, label?: boolean): any; @@ -27,6 +39,7 @@ export abstract class FieldParser { && (this.configData.input.type !== 'list') && (this.configData.input.type !== 'tag') && (this.configData.input.type !== 'group') + && isEmpty(this.configData.selectableRelationship) ) { let arrayCounter = 0; let fieldArrayCounter = 0; @@ -36,6 +49,7 @@ export abstract class FieldParser { label: this.configData.label, initialCount: this.getInitArrayIndex(), notRepeatable: !this.configData.repeatable, + required: isNotEmpty(this.configData.mandatory), groupFactory: () => { let model; if ((arrayCounter === 0)) { @@ -71,7 +85,7 @@ export abstract class FieldParser { } else { const model = this.modelFactory(this.getInitFieldValue()); - if (model.hasLanguages) { + if (model.hasLanguages || isNotEmpty(model.relationship)) { setLayout(model, 'grid', 'control', 'col'); } return model; @@ -164,11 +178,11 @@ export abstract class FieldParser { return ids; } } else { - return null; + return [this.configData.selectableRelationship.relationshipType]; } } - protected initModel(id?: string, label = true, labelEmpty = false, setErrors = true) { + protected initModel(id?: string, label = true, setErrors = true) { const controlModel = Object.create(null); @@ -184,9 +198,15 @@ export abstract class FieldParser { // Set read only option controlModel.readOnly = this.parserOptions.readOnly; controlModel.disabled = this.parserOptions.readOnly; + if (hasValue(this.configData.selectableRelationship)) { + controlModel.relationship = Object.assign(new RelationshipOptions(), this.configData.selectableRelationship); + } + controlModel.repeatable = this.configData.repeatable; + controlModel.metadataFields = isNotEmpty(this.configData.selectableMetadata) ? this.configData.selectableMetadata.map((metadataObject) => metadataObject.metadata) : []; + controlModel.submissionId = this.submissionId; // Set label - this.setLabel(controlModel, label, labelEmpty); + this.setLabel(controlModel, label); controlModel.placeholder = this.configData.label; @@ -204,14 +224,14 @@ export abstract class FieldParser { if (this.configData.languageCodes && this.configData.languageCodes.length > 0) { (controlModel as DsDynamicInputModel).languageCodes = this.configData.languageCodes; } -/* (controlModel as DsDynamicInputModel).languageCodes = [{ - display: 'English', - code: 'en_US' - }, - { - display: 'Italian', - code: 'it_IT' - }];*/ + /* (controlModel as DsDynamicInputModel).languageCodes = [{ + display: 'English', + code: 'en_US' + }, + { + display: 'Italian', + code: 'it_IT' + }];*/ return controlModel; } @@ -222,26 +242,26 @@ export abstract class FieldParser { protected addPatternValidator(controlModel) { const regex = new RegExp(this.configData.input.regex); - controlModel.validators = Object.assign({}, controlModel.validators, {pattern: regex}); + controlModel.validators = Object.assign({}, controlModel.validators, { pattern: regex }); controlModel.errorMessages = Object.assign( {}, controlModel.errorMessages, - {pattern: 'error.validation.pattern'}); + { pattern: 'error.validation.pattern' }); } protected markAsRequired(controlModel) { controlModel.required = true; - controlModel.validators = Object.assign({}, controlModel.validators, {required: null}); + controlModel.validators = Object.assign({}, controlModel.validators, { required: null }); controlModel.errorMessages = Object.assign( {}, controlModel.errorMessages, - {required: this.configData.mandatoryMessage}); + { required: this.configData.mandatoryMessage }); } protected setLabel(controlModel, label = true, labelEmpty = false) { if (label) { - controlModel.label = (labelEmpty) ? ' ' : this.configData.label; + controlModel.label = this.configData.label; } } @@ -253,13 +273,13 @@ export abstract class FieldParser { if (key === 0) { controlModel.value = option.metadata; } - controlModel.options.push({label: option.label, value: option.metadata}); + controlModel.options.push({ label: option.label, value: option.metadata }); }); } } public setAuthorityOptions(controlModel, authorityUuid) { - if (isNotEmpty(this.configData.selectableMetadata[0].authority)) { + if (isNotEmpty(this.configData.selectableMetadata) && isNotEmpty(this.configData.selectableMetadata[0].authority)) { controlModel.authorityOptions = new AuthorityOptions( this.configData.selectableMetadata[0].authority, this.configData.selectableMetadata[0].metadata, diff --git a/src/app/shared/form/builder/parsers/list-field-parser.spec.ts b/src/app/shared/form/builder/parsers/list-field-parser.spec.ts index b2fa0b2089..fab5ec3888 100644 --- a/src/app/shared/form/builder/parsers/list-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/list-field-parser.spec.ts @@ -9,6 +9,7 @@ describe('ListFieldParser test suite', () => { let field: FormFieldModel; let initFormValues = {}; + const submissionId = '1234'; const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', @@ -37,13 +38,13 @@ describe('ListFieldParser test suite', () => { }); it('should init parser properly', () => { - const parser = new ListFieldParser(field, initFormValues, parserOptions); + const parser = new ListFieldParser(submissionId, field, initFormValues, parserOptions); expect(parser instanceof ListFieldParser).toBe(true); }); it('should return a DynamicListCheckboxGroupModel object when repeatable option is true', () => { - const parser = new ListFieldParser(field, initFormValues, parserOptions); + const parser = new ListFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -52,7 +53,7 @@ describe('ListFieldParser test suite', () => { it('should return a DynamicListRadioGroupModel object when repeatable option is false', () => { field.repeatable = false; - const parser = new ListFieldParser(field, initFormValues, parserOptions); + const parser = new ListFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -65,7 +66,7 @@ describe('ListFieldParser test suite', () => { }; const expectedValue = [new FormFieldMetadataValueObject('test type')]; - const parser = new ListFieldParser(field, initFormValues, parserOptions); + const parser = new ListFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); diff --git a/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts b/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts index c45d39d5bb..5e14e0c013 100644 --- a/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/lookup-field-parser.spec.ts @@ -8,6 +8,7 @@ describe('LookupFieldParser test suite', () => { let field: FormFieldModel; let initFormValues = {}; + const submissionId = '1234'; const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', @@ -36,13 +37,13 @@ describe('LookupFieldParser test suite', () => { }); it('should init parser properly', () => { - const parser = new LookupFieldParser(field, initFormValues, parserOptions); + const parser = new LookupFieldParser(submissionId, field, initFormValues, parserOptions); expect(parser instanceof LookupFieldParser).toBe(true); }); it('should return a DynamicLookupModel object when repeatable option is false', () => { - const parser = new LookupFieldParser(field, initFormValues, parserOptions); + const parser = new LookupFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -55,7 +56,7 @@ describe('LookupFieldParser test suite', () => { }; const expectedValue = new FormFieldMetadataValueObject('test journal'); - const parser = new LookupFieldParser(field, initFormValues, parserOptions); + const parser = new LookupFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); diff --git a/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts b/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts index b324ba7a7e..adc1e90166 100644 --- a/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/lookup-name-field-parser.spec.ts @@ -1,7 +1,5 @@ import { FormFieldModel } from '../models/form-field.model'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; -import { LookupFieldParser } from './lookup-field-parser'; -import { DynamicLookupModel } from '../ds-dynamic-form-ui/models/lookup/dynamic-lookup.model'; import { LookupNameFieldParser } from './lookup-name-field-parser'; import { DynamicLookupNameModel } from '../ds-dynamic-form-ui/models/lookup/dynamic-lookup-name.model'; import { ParserOptions } from './parser-options'; @@ -10,6 +8,7 @@ describe('LookupNameFieldParser test suite', () => { let field: FormFieldModel; let initFormValues = {}; + const submissionId = '1234'; const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', @@ -38,13 +37,13 @@ describe('LookupNameFieldParser test suite', () => { }); it('should init parser properly', () => { - const parser = new LookupNameFieldParser(field, initFormValues, parserOptions); + const parser = new LookupNameFieldParser(submissionId, field, initFormValues, parserOptions); expect(parser instanceof LookupNameFieldParser).toBe(true); }); it('should return a DynamicLookupNameModel object when repeatable option is false', () => { - const parser = new LookupNameFieldParser(field, initFormValues, parserOptions); + const parser = new LookupNameFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -57,7 +56,7 @@ describe('LookupNameFieldParser test suite', () => { }; const expectedValue = new FormFieldMetadataValueObject('test author'); - const parser = new LookupNameFieldParser(field, initFormValues, parserOptions); + const parser = new LookupNameFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); diff --git a/src/app/shared/form/builder/parsers/name-field-parser.spec.ts b/src/app/shared/form/builder/parsers/name-field-parser.spec.ts index 889244e8f2..1b0c637030 100644 --- a/src/app/shared/form/builder/parsers/name-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/name-field-parser.spec.ts @@ -10,6 +10,7 @@ describe('NameFieldParser test suite', () => { let field3: FormFieldModel; let initFormValues: any = {}; + const submissionId = '1234'; const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', @@ -69,13 +70,13 @@ describe('NameFieldParser test suite', () => { }); it('should init parser properly', () => { - const parser = new NameFieldParser(field1, initFormValues, parserOptions); + const parser = new NameFieldParser(submissionId, field1, initFormValues, parserOptions); expect(parser instanceof NameFieldParser).toBe(true); }); it('should return a DynamicConcatModel object when repeatable option is false', () => { - const parser = new NameFieldParser(field2, initFormValues, parserOptions); + const parser = new NameFieldParser(submissionId, field2, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -83,7 +84,7 @@ describe('NameFieldParser test suite', () => { }); it('should return a DynamicConcatModel object with the correct separator', () => { - const parser = new NameFieldParser(field2, initFormValues, parserOptions); + const parser = new NameFieldParser(submissionId, field2, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -96,7 +97,7 @@ describe('NameFieldParser test suite', () => { }; const expectedValue = new FormFieldMetadataValueObject('test, name'); - const parser = new NameFieldParser(field1, initFormValues, parserOptions); + const parser = new NameFieldParser(submissionId, field1, initFormValues, parserOptions); const fieldModel = parser.parse(); diff --git a/src/app/shared/form/builder/parsers/name-field-parser.ts b/src/app/shared/form/builder/parsers/name-field-parser.ts index 896b3cc478..e5ecb034ea 100644 --- a/src/app/shared/form/builder/parsers/name-field-parser.ts +++ b/src/app/shared/form/builder/parsers/name-field-parser.ts @@ -1,10 +1,17 @@ +import { Inject } from '@angular/core'; import { FormFieldModel } from '../models/form-field.model'; import { ConcatFieldParser } from './concat-field-parser'; +import { CONFIG_DATA, INIT_FORM_VALUES, PARSER_OPTIONS, SUBMISSION_ID } from './field-parser'; import { ParserOptions } from './parser-options'; export class NameFieldParser extends ConcatFieldParser { - constructor(protected configData: FormFieldModel, protected initFormValues, protected parserOptions: ParserOptions) { - super(configData, initFormValues, parserOptions, ',', 'form.last-name', 'form.first-name'); + constructor( + @Inject(SUBMISSION_ID) submissionId: string, + @Inject(CONFIG_DATA) configData: FormFieldModel, + @Inject(INIT_FORM_VALUES) initFormValues, + @Inject(PARSER_OPTIONS) parserOptions: ParserOptions + ) { + super(submissionId, configData, initFormValues, parserOptions, ',', 'form.last-name', 'form.first-name'); } } diff --git a/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts b/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts index 89c576bf3a..4668b3017d 100644 --- a/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/onebox-field-parser.spec.ts @@ -10,6 +10,7 @@ describe('OneboxFieldParser test suite', () => { let field2: FormFieldModel; let field3: FormFieldModel; + const submissionId = '1234'; const initFormValues = {}; const parserOptions: ParserOptions = { readOnly: false, @@ -70,13 +71,13 @@ describe('OneboxFieldParser test suite', () => { }); it('should init parser properly', () => { - const parser = new OneboxFieldParser(field1, initFormValues, parserOptions); + const parser = new OneboxFieldParser(submissionId, field1, initFormValues, parserOptions); expect(parser instanceof OneboxFieldParser).toBe(true); }); it('should return a DynamicQualdropModel object when selectableMetadata is multiple', () => { - const parser = new OneboxFieldParser(field2, initFormValues, parserOptions); + const parser = new OneboxFieldParser(submissionId, field2, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -84,7 +85,7 @@ describe('OneboxFieldParser test suite', () => { }); it('should return a DsDynamicInputModel object when selectableMetadata is not multiple', () => { - const parser = new OneboxFieldParser(field3, initFormValues, parserOptions); + const parser = new OneboxFieldParser(submissionId, field3, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -92,7 +93,7 @@ describe('OneboxFieldParser test suite', () => { }); it('should return a DynamicTypeaheadModel object when selectableMetadata has authority', () => { - const parser = new OneboxFieldParser(field1, initFormValues, parserOptions); + const parser = new OneboxFieldParser(submissionId, field1, initFormValues, parserOptions); const fieldModel = parser.parse(); diff --git a/src/app/shared/form/builder/parsers/onebox-field-parser.ts b/src/app/shared/form/builder/parsers/onebox-field-parser.ts index 284656cc95..d69c9d4677 100644 --- a/src/app/shared/form/builder/parsers/onebox-field-parser.ts +++ b/src/app/shared/form/builder/parsers/onebox-field-parser.ts @@ -56,8 +56,10 @@ export class OneboxFieldParser extends FieldParser { inputSelectGroup.group = []; inputSelectGroup.legend = this.configData.label; inputSelectGroup.hint = this.configData.hints; + this.setLabel(inputSelectGroup, label); + inputSelectGroup.required = isNotEmpty(this.configData.mandatory); - const selectModelConfig: DynamicSelectModelConfig = this.initModel(newId + QUALDROP_METADATA_SUFFIX, label); + const selectModelConfig: DynamicSelectModelConfig = this.initModel(newId + QUALDROP_METADATA_SUFFIX, label, false); selectModelConfig.hint = null; this.setOptions(selectModelConfig); if (isNotEmpty(fieldValue)) { @@ -65,11 +67,11 @@ export class OneboxFieldParser extends FieldParser { } inputSelectGroup.group.push(new DynamicSelectModel(selectModelConfig, clsSelect)); - const inputModelConfig: DsDynamicInputModelConfig = this.initModel(newId + QUALDROP_VALUE_SUFFIX, label, true); + const inputModelConfig: DsDynamicInputModelConfig = this.initModel(newId + QUALDROP_VALUE_SUFFIX, label, false); inputModelConfig.hint = null; this.setValues(inputModelConfig, fieldValue); - inputSelectGroup.readOnly = selectModelConfig.disabled && inputModelConfig.readOnly; + inputSelectGroup.group.push(new DsDynamicInputModel(inputModelConfig, clsInput)); return new DynamicQualdropModel(inputSelectGroup, clsGroup); diff --git a/src/app/shared/form/builder/parsers/parser-factory.ts b/src/app/shared/form/builder/parsers/parser-factory.ts index 2cbee18783..d674007da4 100644 --- a/src/app/shared/form/builder/parsers/parser-factory.ts +++ b/src/app/shared/form/builder/parsers/parser-factory.ts @@ -1,6 +1,12 @@ +import { StaticProvider } from '@angular/core'; import { ParserType } from './parser-type'; -import { GenericConstructor } from '../../../../core/shared/generic-constructor'; -import { FieldParser } from './field-parser'; +import { + CONFIG_DATA, + FieldParser, + INIT_FORM_VALUES, + PARSER_OPTIONS, + SUBMISSION_ID +} from './field-parser'; import { DateFieldParser } from './date-field-parser'; import { DropdownFieldParser } from './dropdown-field-parser'; import { RelationGroupFieldParser } from './relation-group-field-parser'; @@ -12,44 +18,105 @@ import { NameFieldParser } from './name-field-parser'; import { SeriesFieldParser } from './series-field-parser'; import { TagFieldParser } from './tag-field-parser'; import { TextareaFieldParser } from './textarea-field-parser'; +import { DisabledFieldParser } from './disabled-field-parser'; +const fieldParserDeps = [ + SUBMISSION_ID, + CONFIG_DATA, + INIT_FORM_VALUES, + PARSER_OPTIONS, +]; + +/** + * Method to retrieve a field parder with its providers based on the input type + */ export class ParserFactory { - public static getConstructor(type: ParserType): GenericConstructor { + public static getProvider(type: ParserType): StaticProvider { switch (type) { case ParserType.Date: { - return DateFieldParser + return { + provide: FieldParser, + useClass: DateFieldParser, + deps: [...fieldParserDeps] + } } case ParserType.Dropdown: { - return DropdownFieldParser + return { + provide: FieldParser, + useClass: DropdownFieldParser, + deps: [...fieldParserDeps] + } } case ParserType.RelationGroup: { - return RelationGroupFieldParser + return { + provide: FieldParser, + useClass: RelationGroupFieldParser, + deps: [...fieldParserDeps] + } } case ParserType.List: { - return ListFieldParser + return { + provide: FieldParser, + useClass: ListFieldParser, + deps: [...fieldParserDeps] + } } case ParserType.Lookup: { - return LookupFieldParser + return { + provide: FieldParser, + useClass: LookupFieldParser, + deps: [...fieldParserDeps] + } } case ParserType.LookupName: { - return LookupNameFieldParser + return { + provide: FieldParser, + useClass: LookupNameFieldParser, + deps: [...fieldParserDeps] + } } case ParserType.Onebox: { - return OneboxFieldParser + return { + provide: FieldParser, + useClass: OneboxFieldParser, + deps: [...fieldParserDeps] + } } case ParserType.Name: { - return NameFieldParser + return { + provide: FieldParser, + useClass: NameFieldParser, + deps: [...fieldParserDeps] + } } case ParserType.Series: { - return SeriesFieldParser + return { + provide: FieldParser, + useClass: SeriesFieldParser, + deps: [...fieldParserDeps] + } } case ParserType.Tag: { - return TagFieldParser + return { + provide: FieldParser, + useClass: TagFieldParser, + deps: [...fieldParserDeps] + } } case ParserType.Textarea: { - return TextareaFieldParser + return { + provide: FieldParser, + useClass: TextareaFieldParser, + deps: [...fieldParserDeps] + } + } + case undefined: { + return { + provide: FieldParser, + useClass: DisabledFieldParser, + deps: [...fieldParserDeps] + } } - default: { return undefined; } diff --git a/src/app/shared/form/builder/parsers/parser-type.ts b/src/app/shared/form/builder/parsers/parser-type.ts index a9af87d73f..f43d4654a0 100644 --- a/src/app/shared/form/builder/parsers/parser-type.ts +++ b/src/app/shared/form/builder/parsers/parser-type.ts @@ -5,6 +5,7 @@ export enum ParserType { List = 'list', Lookup = 'lookup', LookupName = 'lookup-name', + LookupRelation = 'lookup-relation', Onebox = 'onebox', Name = 'name', Series = 'series', diff --git a/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts b/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts index e6bf0dc2c8..84f3df0365 100644 --- a/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/relation-group-field-parser.spec.ts @@ -8,6 +8,7 @@ describe('RelationGroupFieldParser test suite', () => { let field: FormFieldModel; let initFormValues = {}; + const submissionId = '1234'; const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', @@ -71,13 +72,13 @@ describe('RelationGroupFieldParser test suite', () => { }); it('should init parser properly', () => { - const parser = new RelationGroupFieldParser(field, initFormValues, parserOptions); + const parser = new RelationGroupFieldParser(submissionId, field, initFormValues, parserOptions); expect(parser instanceof RelationGroupFieldParser).toBe(true); }); it('should return a DynamicRelationGroupModel object', () => { - const parser = new RelationGroupFieldParser(field, initFormValues, parserOptions); + const parser = new RelationGroupFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -86,7 +87,7 @@ describe('RelationGroupFieldParser test suite', () => { it('should throw when rows configuration is empty', () => { field.rows = null; - const parser = new RelationGroupFieldParser(field, initFormValues, parserOptions); + const parser = new RelationGroupFieldParser(submissionId, field, initFormValues, parserOptions); expect(() => parser.parse()) .toThrow(); @@ -97,7 +98,7 @@ describe('RelationGroupFieldParser test suite', () => { author: [new FormFieldMetadataValueObject('test author')], affiliation: [new FormFieldMetadataValueObject('test affiliation')] }; - const parser = new RelationGroupFieldParser(field, initFormValues, parserOptions); + const parser = new RelationGroupFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); const expectedValue = [{ diff --git a/src/app/shared/form/builder/parsers/relation-group-field-parser.ts b/src/app/shared/form/builder/parsers/relation-group-field-parser.ts index b3f6e749f3..01699d9e78 100644 --- a/src/app/shared/form/builder/parsers/relation-group-field-parser.ts +++ b/src/app/shared/form/builder/parsers/relation-group-field-parser.ts @@ -15,6 +15,7 @@ export class RelationGroupFieldParser extends FieldParser { public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean) { const modelConfiguration: DynamicRelationGroupModelConfig = this.initModel(null, label); + modelConfiguration.submissionId = this.submissionId; modelConfiguration.scopeUUID = this.parserOptions.authorityUuid; modelConfiguration.submissionScope = this.parserOptions.submissionScope; if (this.configData && this.configData.rows && this.configData.rows.length > 0) { diff --git a/src/app/shared/form/builder/parsers/row-parser.spec.ts b/src/app/shared/form/builder/parsers/row-parser.spec.ts index 58b1d1de99..435c6a6426 100644 --- a/src/app/shared/form/builder/parsers/row-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/row-parser.spec.ts @@ -17,6 +17,7 @@ describe('RowParser test suite', () => { let row9: FormRowModel; let row10: FormRowModel; + const submissionId = '1234'; const scopeUUID = 'testScopeUUID'; const initFormValues = {}; const submissionScope = 'WORKSPACE'; @@ -328,76 +329,98 @@ describe('RowParser test suite', () => { }); it('should init parser properly', () => { - let parser = new RowParser(row1, scopeUUID, initFormValues, submissionScope, readOnly); - - expect(parser instanceof RowParser).toBe(true); - - parser = new RowParser(row2, scopeUUID, initFormValues, submissionScope, readOnly); - - expect(parser instanceof RowParser).toBe(true); - - parser = new RowParser(row3, scopeUUID, initFormValues, submissionScope, readOnly); - - expect(parser instanceof RowParser).toBe(true); - - parser = new RowParser(row4, scopeUUID, initFormValues, submissionScope, readOnly); - - expect(parser instanceof RowParser).toBe(true); - - parser = new RowParser(row5, scopeUUID, initFormValues, submissionScope, readOnly); - - expect(parser instanceof RowParser).toBe(true); - - parser = new RowParser(row6, scopeUUID, initFormValues, submissionScope, readOnly); - - expect(parser instanceof RowParser).toBe(true); - - parser = new RowParser(row7, scopeUUID, initFormValues, submissionScope, readOnly); - - expect(parser instanceof RowParser).toBe(true); - - parser = new RowParser(row8, scopeUUID, initFormValues, submissionScope, readOnly); - - expect(parser instanceof RowParser).toBe(true); - - parser = new RowParser(row9, scopeUUID, initFormValues, submissionScope, readOnly); - - expect(parser instanceof RowParser).toBe(true); - - parser = new RowParser(row10, scopeUUID, initFormValues, submissionScope, readOnly); + const parser = new RowParser(undefined); expect(parser instanceof RowParser).toBe(true); }); - it('should return a DynamicRowGroupModel object', () => { - const parser = new RowParser(row1, scopeUUID, initFormValues, submissionScope, readOnly); + describe('parse', () => { + it('should return a DynamicRowGroupModel object', () => { + const parser = new RowParser(undefined); - const rowModel = parser.parse(); + const rowModel = parser.parse(submissionId, row1, scopeUUID, initFormValues, submissionScope, readOnly); - expect(rowModel instanceof DynamicRowGroupModel).toBe(true); - }); + expect(rowModel instanceof DynamicRowGroupModel).toBe(true); + }); - it('should return a row with three fields', () => { - const parser = new RowParser(row1, scopeUUID, initFormValues, submissionScope, readOnly); + it('should return a row with three fields', () => { + const parser = new RowParser(undefined); - const rowModel = parser.parse(); + const rowModel = parser.parse(submissionId, row1, scopeUUID, initFormValues, submissionScope, readOnly); - expect((rowModel as DynamicRowGroupModel).group.length).toBe(3); - }); + expect((rowModel as DynamicRowGroupModel).group.length).toBe(3); + }); - it('should return a DynamicRowArrayModel object', () => { - const parser = new RowParser(row2, scopeUUID, initFormValues, submissionScope, readOnly); + it('should return a DynamicRowArrayModel object', () => { + const parser = new RowParser(undefined); - const rowModel = parser.parse(); + const rowModel = parser.parse(submissionId, row2, scopeUUID, initFormValues, submissionScope, readOnly); - expect(rowModel instanceof DynamicRowArrayModel).toBe(true); - }); + expect(rowModel instanceof DynamicRowArrayModel).toBe(true); + }); - it('should return a row that contains only scoped fields', () => { - const parser = new RowParser(row3, scopeUUID, initFormValues, submissionScope, readOnly); + it('should return a row that contains only scoped fields', () => { + const parser = new RowParser(undefined); - const rowModel = parser.parse(); + const rowModel = parser.parse(submissionId, row3, scopeUUID, initFormValues, submissionScope, readOnly); - expect((rowModel as DynamicRowGroupModel).group.length).toBe(1); + expect((rowModel as DynamicRowGroupModel).group.length).toBe(1); + }); + + it('should be able to parse a dropdown combo field', () => { + const parser = new RowParser(undefined); + + const rowModel = parser.parse(submissionId, row4, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(rowModel).toBeDefined(); + }); + + it('should be able to parse a lookup-name field', () => { + const parser = new RowParser(undefined); + + const rowModel = parser.parse(submissionId, row5, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(rowModel).toBeDefined(); + }); + + it('should be able to parse a list field', () => { + const parser = new RowParser(undefined); + + const rowModel = parser.parse(submissionId, row6, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(rowModel).toBeDefined(); + }); + + it('should be able to parse a date field', () => { + const parser = new RowParser(undefined); + + const rowModel = parser.parse(submissionId, row7, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(rowModel).toBeDefined(); + }); + + it('should be able to parse a tag field', () => { + const parser = new RowParser(undefined); + + const rowModel = parser.parse(submissionId, row8, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(rowModel).toBeDefined(); + }); + + it('should be able to parse a textarea field', () => { + const parser = new RowParser(undefined); + + const rowModel = parser.parse(submissionId, row9, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(rowModel).toBeDefined(); + }); + + it('should be able to parse a group field', () => { + const parser = new RowParser(undefined); + + const rowModel = parser.parse(submissionId, row10, scopeUUID, initFormValues, submissionScope, readOnly); + + expect(rowModel).toBeDefined(); + }); }); }); diff --git a/src/app/shared/form/builder/parsers/row-parser.ts b/src/app/shared/form/builder/parsers/row-parser.ts index 0bb8a0e89a..4938b9859e 100644 --- a/src/app/shared/form/builder/parsers/row-parser.ts +++ b/src/app/shared/form/builder/parsers/row-parser.ts @@ -1,30 +1,46 @@ -import { DYNAMIC_FORM_CONTROL_TYPE_ARRAY, DynamicFormGroupModelConfig } from '@ng-dynamic-forms/core'; +import { Injectable, Injector } from '@angular/core'; +import { + DYNAMIC_FORM_CONTROL_TYPE_ARRAY, + DynamicFormGroupModelConfig +} from '@ng-dynamic-forms/core'; import { uniqueId } from 'lodash'; import { IntegrationSearchOptions } from '../../../../core/integration/models/integration-options.model'; -import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from '../ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; -import { DynamicRowGroupModel } from '../ds-dynamic-form-ui/models/ds-dynamic-row-group-model'; import { isEmpty } from '../../../empty.util'; -import { setLayout } from './parser.utils'; +import { DynamicRowGroupModel } from '../ds-dynamic-form-ui/models/ds-dynamic-row-group-model'; +import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from '../ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; import { FormFieldModel } from '../models/form-field.model'; -import { ParserType } from './parser-type'; -import { ParserOptions } from './parser-options'; +import { + CONFIG_DATA, + FieldParser, + INIT_FORM_VALUES, + PARSER_OPTIONS, + SUBMISSION_ID +} from './field-parser'; import { ParserFactory } from './parser-factory'; +import { ParserOptions } from './parser-options'; +import { ParserType } from './parser-type'; +import { setLayout } from './parser.utils'; export const ROW_ID_PREFIX = 'df-row-group-config-'; -export class RowParser { - protected authorityOptions: IntegrationSearchOptions; +@Injectable({ + providedIn: 'root' +}) - constructor(protected rowData, - protected scopeUUID, - protected initFormValues: any, - protected submissionScope, - protected readOnly: boolean) { - this.authorityOptions = new IntegrationSearchOptions(scopeUUID); +/** + * Parser the submission data for a single row + */ +export class RowParser { + constructor(private parentInjector: Injector) { } - public parse(): DynamicRowGroupModel { + public parse(submissionId: string, + rowData, + scopeUUID, + initFormValues: any, + submissionScope, + readOnly: boolean): DynamicRowGroupModel { let fieldModel: any = null; let parsedResult = null; const config: DynamicFormGroupModelConfig = { @@ -32,31 +48,44 @@ export class RowParser { group: [], }; - const scopedFields: FormFieldModel[] = this.filterScopedFields(this.rowData.fields); + const authorityOptions = new IntegrationSearchOptions(scopeUUID); + + const scopedFields: FormFieldModel[] = this.filterScopedFields(rowData.fields, submissionScope); const layoutDefaultGridClass = ' col-sm-' + Math.trunc(12 / scopedFields.length); const layoutClass = ' d-flex flex-column justify-content-start'; const parserOptions: ParserOptions = { - readOnly: this.readOnly, - submissionScope: this.submissionScope, - authorityUuid: this.authorityOptions.uuid + readOnly: readOnly, + submissionScope: submissionScope, + authorityUuid: authorityOptions.uuid }; // Iterate over row's fields scopedFields.forEach((fieldData: FormFieldModel) => { const layoutFieldClass = (fieldData.style || layoutDefaultGridClass) + layoutClass; - const parserCo = ParserFactory.getConstructor(fieldData.input.type as ParserType); - if (parserCo) { - fieldModel = new parserCo(fieldData, this.initFormValues, parserOptions).parse(); + const parserProvider = ParserFactory.getProvider(fieldData.input.type as ParserType); + if (parserProvider) { + const fieldInjector = Injector.create({ + providers: [ + parserProvider, + { provide: SUBMISSION_ID, useValue: submissionId }, + { provide: CONFIG_DATA, useValue: fieldData }, + { provide: INIT_FORM_VALUES, useValue: initFormValues }, + { provide: PARSER_OPTIONS, useValue: parserOptions } + ], + parent: this.parentInjector + }); + + fieldModel = fieldInjector.get(FieldParser).parse(); } else { - throw new Error(`unknown form control model type "${fieldData.input.type}" defined for Input field with label "${fieldData.label}".`, ); + throw new Error(`unknown form control model type "${fieldData.input.type}" defined for Input field with label "${fieldData.label}".`,); } if (fieldModel) { if (fieldModel.type === DYNAMIC_FORM_CONTROL_TYPE_ARRAY || fieldModel.type === DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP) { - if (this.rowData.fields.length > 1) { + if (rowData.fields.length > 1) { setLayout(fieldModel, 'grid', 'host', layoutFieldClass); config.group.push(fieldModel); // if (isEmpty(parsedResult)) { @@ -98,15 +127,15 @@ export class RowParser { return parsedResult; } - checksFieldScope(fieldScope) { - return (isEmpty(fieldScope) || isEmpty(this.submissionScope) || fieldScope === this.submissionScope); + checksFieldScope(fieldScope, submissionScope) { + return (isEmpty(fieldScope) || isEmpty(submissionScope) || fieldScope === submissionScope); } - filterScopedFields(fields: FormFieldModel[]): FormFieldModel[] { + filterScopedFields(fields: FormFieldModel[], submissionScope): FormFieldModel[] { const filteredFields: FormFieldModel[] = []; fields.forEach((field: FormFieldModel) => { // Whether field scope doesn't match the submission scope, skip it - if (this.checksFieldScope(field.scope)) { + if (this.checksFieldScope(field.scope, submissionScope)) { filteredFields.push(field); } }); diff --git a/src/app/shared/form/builder/parsers/series-field-parser.spec.ts b/src/app/shared/form/builder/parsers/series-field-parser.spec.ts index 95351d027f..ceb4e96320 100644 --- a/src/app/shared/form/builder/parsers/series-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/series-field-parser.spec.ts @@ -8,6 +8,7 @@ describe('SeriesFieldParser test suite', () => { let field: FormFieldModel; let initFormValues: any = {}; + const submissionId = '1234'; const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', @@ -32,13 +33,13 @@ describe('SeriesFieldParser test suite', () => { }); it('should init parser properly', () => { - const parser = new SeriesFieldParser(field, initFormValues, parserOptions); + const parser = new SeriesFieldParser(submissionId, field, initFormValues, parserOptions); expect(parser instanceof SeriesFieldParser).toBe(true); }); it('should return a DynamicConcatModel object when repeatable option is false', () => { - const parser = new SeriesFieldParser(field, initFormValues, parserOptions); + const parser = new SeriesFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -46,7 +47,7 @@ describe('SeriesFieldParser test suite', () => { }); it('should return a DynamicConcatModel object with the correct separator', () => { - const parser = new SeriesFieldParser(field, initFormValues, parserOptions); + const parser = new SeriesFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -59,7 +60,7 @@ describe('SeriesFieldParser test suite', () => { }; const expectedValue = new FormFieldMetadataValueObject('test; series'); - const parser = new SeriesFieldParser(field, initFormValues, parserOptions); + const parser = new SeriesFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); diff --git a/src/app/shared/form/builder/parsers/series-field-parser.ts b/src/app/shared/form/builder/parsers/series-field-parser.ts index 9857b4e993..36ee9c36c1 100644 --- a/src/app/shared/form/builder/parsers/series-field-parser.ts +++ b/src/app/shared/form/builder/parsers/series-field-parser.ts @@ -1,10 +1,17 @@ +import { Inject } from '@angular/core'; import { FormFieldModel } from '../models/form-field.model'; import { ConcatFieldParser } from './concat-field-parser'; +import { CONFIG_DATA, INIT_FORM_VALUES, PARSER_OPTIONS, SUBMISSION_ID } from './field-parser'; import { ParserOptions } from './parser-options'; export class SeriesFieldParser extends ConcatFieldParser { - constructor(protected configData: FormFieldModel, protected initFormValues, protected parserOptions: ParserOptions) { - super(configData, initFormValues, parserOptions, ';'); + constructor( + @Inject(SUBMISSION_ID) submissionId: string, + @Inject(CONFIG_DATA) configData: FormFieldModel, + @Inject(INIT_FORM_VALUES) initFormValues, + @Inject(PARSER_OPTIONS) parserOptions: ParserOptions + ) { + super(submissionId, configData, initFormValues, parserOptions, ';'); } } diff --git a/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts b/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts index 3051dc6395..90449e62e5 100644 --- a/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/tag-field-parser.spec.ts @@ -8,6 +8,7 @@ describe('TagFieldParser test suite', () => { let field: FormFieldModel; let initFormValues: any = {}; + const submissionId = '1234'; const parserOptions: ParserOptions = { readOnly: false, submissionScope: 'testScopeUUID', @@ -36,13 +37,13 @@ describe('TagFieldParser test suite', () => { }); it('should init parser properly', () => { - const parser = new TagFieldParser(field, initFormValues, parserOptions); + const parser = new TagFieldParser(submissionId, field, initFormValues, parserOptions); expect(parser instanceof TagFieldParser).toBe(true); }); it('should return a DynamicTagModel object when repeatable option is false', () => { - const parser = new TagFieldParser(field, initFormValues, parserOptions); + const parser = new TagFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -57,7 +58,7 @@ describe('TagFieldParser test suite', () => { ], }; - const parser = new TagFieldParser(field, initFormValues, parserOptions); + const parser = new TagFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); diff --git a/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts b/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts index c26d758e48..167f126cf2 100644 --- a/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts +++ b/src/app/shared/form/builder/parsers/textarea-field-parser.spec.ts @@ -8,6 +8,7 @@ describe('TextareaFieldParser test suite', () => { let field: FormFieldModel; let initFormValues: any = {}; + const submissionId = '1234'; const parserOptions: ParserOptions = { readOnly: false, submissionScope: null, @@ -34,13 +35,13 @@ describe('TextareaFieldParser test suite', () => { }); it('should init parser properly', () => { - const parser = new TextareaFieldParser(field, initFormValues, parserOptions); + const parser = new TextareaFieldParser(submissionId, field, initFormValues, parserOptions); expect(parser instanceof TextareaFieldParser).toBe(true); }); it('should return a DsDynamicTextAreaModel object when repeatable option is false', () => { - const parser = new TextareaFieldParser(field, initFormValues, parserOptions); + const parser = new TextareaFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); @@ -55,7 +56,7 @@ describe('TextareaFieldParser test suite', () => { }; const expectedValue ='test description'; - const parser = new TextareaFieldParser(field, initFormValues, parserOptions); + const parser = new TextareaFieldParser(submissionId, field, initFormValues, parserOptions); const fieldModel = parser.parse(); diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html index 21d4a81659..510bf7291b 100644 --- a/src/app/shared/form/form.component.html +++ b/src/app/shared/form/form.component.html @@ -13,10 +13,10 @@ - +
-
+
- +
-
+
- +
diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.scss b/src/app/shared/search/search-sidebar/search-sidebar.component.scss similarity index 100% rename from src/app/+search-page/search-sidebar/search-sidebar.component.scss rename to src/app/shared/search/search-sidebar/search-sidebar.component.scss diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.spec.ts b/src/app/shared/search/search-sidebar/search-sidebar.component.spec.ts similarity index 100% rename from src/app/+search-page/search-sidebar/search-sidebar.component.spec.ts rename to src/app/shared/search/search-sidebar/search-sidebar.component.spec.ts diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.ts b/src/app/shared/search/search-sidebar/search-sidebar.component.ts similarity index 93% rename from src/app/+search-page/search-sidebar/search-sidebar.component.ts rename to src/app/shared/search/search-sidebar/search-sidebar.component.ts index 9ee0a74942..42e8a444bc 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.component.ts +++ b/src/app/shared/search/search-sidebar/search-sidebar.component.ts @@ -34,6 +34,11 @@ export class SearchSidebarComponent { */ @Input() viewModeList; + /** + * Whether to show the view mode switch + */ + @Input() showViewModes = true; + /** * True when the search component should show results on the current page */ diff --git a/src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts b/src/app/shared/search/search-switch-configuration/search-configuration-option.model.ts similarity index 100% rename from src/app/+search-page/search-switch-configuration/search-configuration-option.model.ts rename to src/app/shared/search/search-switch-configuration/search-configuration-option.model.ts diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html b/src/app/shared/search/search-switch-configuration/search-switch-configuration.component.html similarity index 100% rename from src/app/+search-page/search-switch-configuration/search-switch-configuration.component.html rename to src/app/shared/search/search-switch-configuration/search-switch-configuration.component.html diff --git a/src/app/shared/search/search-switch-configuration/search-switch-configuration.component.scss b/src/app/shared/search/search-switch-configuration/search-switch-configuration.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts b/src/app/shared/search/search-switch-configuration/search-switch-configuration.component.spec.ts similarity index 87% rename from src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts rename to src/app/shared/search/search-switch-configuration/search-switch-configuration.component.spec.ts index 602dee33e6..05108905f2 100644 --- a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.spec.ts +++ b/src/app/shared/search/search-switch-configuration/search-switch-configuration.component.spec.ts @@ -6,13 +6,16 @@ import { of as observableOf } from 'rxjs'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { SearchSwitchConfigurationComponent } from './search-switch-configuration.component'; -import { MYDSPACE_ROUTE, SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; -import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service-stub'; -import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; import { NavigationExtras, Router } from '@angular/router'; -import { RouterStub } from '../../shared/testing/router-stub'; -import { MyDSpaceConfigurationValueType } from '../../+my-dspace-page/my-dspace-configuration-value-type'; -import { SearchService } from '../search-service/search.service'; +import { SearchConfigurationServiceStub } from '../../testing/search-configuration-service-stub'; +import { RouterStub } from '../../testing/router-stub'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { + MYDSPACE_ROUTE, + SEARCH_CONFIG_SERVICE +} from '../../../+my-dspace-page/my-dspace-page.component'; +import { MyDSpaceConfigurationValueType } from '../../../+my-dspace-page/my-dspace-configuration-value-type'; +import { MockTranslateLoader } from '../../mocks/mock-translate-loader'; describe('SearchSwitchConfigurationComponent', () => { diff --git a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts b/src/app/shared/search/search-switch-configuration/search-switch-configuration.component.ts similarity index 83% rename from src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts rename to src/app/shared/search/search-switch-configuration/search-switch-configuration.component.ts index 1ce1bf84ec..73312e072e 100644 --- a/src/app/+search-page/search-switch-configuration/search-switch-configuration.component.ts +++ b/src/app/shared/search/search-switch-configuration/search-switch-configuration.component.ts @@ -3,12 +3,13 @@ import { NavigationExtras, Router } from '@angular/router'; import { Subscription } from 'rxjs'; -import { hasValue } from '../../shared/empty.util'; -import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; -import { SearchConfigurationService } from '../search-service/search-configuration.service'; -import { MyDSpaceConfigurationValueType } from '../../+my-dspace-page/my-dspace-configuration-value-type'; +import { hasValue } from '../../empty.util'; +import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { MyDSpaceConfigurationValueType } from '../../../+my-dspace-page/my-dspace-configuration-value-type'; import { SearchConfigurationOption } from './search-configuration-option.model'; -import { SearchService } from '../search-service/search.service'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { currentPath } from '../../utils/route.utils'; @Component({ selector: 'ds-search-switch-configuration', @@ -87,7 +88,7 @@ export class SearchSwitchConfigurationComponent implements OnDestroy, OnInit { */ public getSearchLink(): string { if (this.inPlaceSearch) { - return './'; + return currentPath(this.router); } return this.searchService.getSearchLink(); } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 68a9049cb5..eb73514d76 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -130,6 +130,32 @@ import { RoleDirective } from './roles/role.directive'; import { UserMenuComponent } from './auth-nav-menu/user-menu/user-menu.component'; import { ClaimedTaskActionsReturnToPoolComponent } from './mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component'; import { ItemDetailPreviewFieldComponent } from './object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component'; +import { DsDynamicLookupRelationModalComponent } from './form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component'; +import { SearchResultsComponent } from './search/search-results/search-results.component'; +import { SearchSidebarComponent } from './search/search-sidebar/search-sidebar.component'; +import { SearchSettingsComponent } from './search/search-settings/search-settings.component'; +import { CollectionSearchResultGridElementComponent } from './object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component'; +import { CommunitySearchResultGridElementComponent } from './object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component'; +import { SearchFiltersComponent } from './search/search-filters/search-filters.component'; +import { SearchFilterComponent } from './search/search-filters/search-filter/search-filter.component'; +import { SearchFacetFilterComponent } from './search/search-filters/search-filter/search-facet-filter/search-facet-filter.component'; +import { SearchLabelsComponent } from './search/search-labels/search-labels.component'; +import { SearchFacetFilterWrapperComponent } from './search/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component'; +import { SearchRangeFilterComponent } from './search/search-filters/search-filter/search-range-filter/search-range-filter.component'; +import { SearchTextFilterComponent } from './search/search-filters/search-filter/search-text-filter/search-text-filter.component'; +import { SearchHierarchyFilterComponent } from './search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component'; +import { SearchBooleanFilterComponent } from './search/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component'; +import { SearchFacetOptionComponent } from './search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component'; +import { SearchFacetSelectedOptionComponent } from './search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component'; +import { SearchFacetRangeOptionComponent } from './search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component'; +import { SearchSwitchConfigurationComponent } from './search/search-switch-configuration/search-switch-configuration.component'; +import { SearchAuthorityFilterComponent } from './search/search-filters/search-filter/search-authority-filter/search-authority-filter.component'; +import { DsDynamicDisabledComponent } from './form/builder/ds-dynamic-form-ui/models/disabled/dynamic-disabled.component'; +import { DsDynamicLookupRelationSearchTabComponent } from './form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component'; +import { DsDynamicLookupRelationSelectionTabComponent } from './form/builder/ds-dynamic-form-ui/relation-lookup-modal/selection-tab/dynamic-lookup-relation-selection-tab.component'; +import { PageSizeSelectorComponent } from './page-size-selector/page-size-selector.component'; +import { AbstractTrackableComponent } from './trackable/abstract-trackable.component'; +import { ComcolMetadataComponent } from './comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component'; import { ItemSelectComponent } from './object-select/item-select/item-select.component'; import { CollectionSelectComponent } from './object-select/collection-select/collection-select.component'; import { FilterInputSuggestionsComponent } from './input-suggestions/filter-suggestions/filter-input-suggestions.component'; @@ -142,13 +168,14 @@ import { ListableObjectComponentLoaderComponent } from './object-collection/shar import { PublicationSearchResultListElementComponent } from './object-list/search-result-list-element/item-search-result/item-types/publication/publication-search-result-list-element.component'; import { PublicationSearchResultGridElementComponent } from './object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component'; import { ListableObjectDirective } from './object-collection/shared/listable-object/listable-object.directive'; -import { CommunitySearchResultGridElementComponent } from './object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component'; -import { CollectionSearchResultGridElementComponent } from './object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component'; +import { SearchLabelComponent } from './search/search-labels/search-label/search-label.component'; import { ItemMetadataRepresentationListElementComponent } from './object-list/metadata-representation-list-element/item/item-metadata-representation-list-element.component'; import { PageWithSidebarComponent } from './sidebar/page-with-sidebar.component'; import { SidebarDropdownComponent } from './sidebar/sidebar-dropdown.component'; import { SidebarFilterComponent } from './sidebar/filter/sidebar-filter.component'; import { SidebarFilterSelectedOptionComponent } from './sidebar/filter/sidebar-filter-selected-option.component'; +import { MetadataRepresentationListComponent } from '../+item-page/simple/metadata-representation-list/metadata-representation-list.component'; +import { SelectableListItemControlComponent } from './object-collection/shared/selectable-list-item-control/selectable-list-item-control.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -210,6 +237,8 @@ const COMPONENTS = [ DsDynamicFormControlContainerComponent, DsDynamicListComponent, DsDynamicLookupComponent, + DsDynamicDisabledComponent, + DsDynamicLookupRelationModalComponent, DsDynamicScrollableDropdownComponent, DsDynamicTagComponent, DsDynamicTypeaheadComponent, @@ -268,6 +297,29 @@ const COMPONENTS = [ EditItemSelectorComponent, CommunitySearchResultListElementComponent, CollectionSearchResultListElementComponent, + BrowseByComponent, + SearchResultsComponent, + SearchSidebarComponent, + SearchSettingsComponent, + CollectionSearchResultGridElementComponent, + CommunitySearchResultGridElementComponent, + SearchFiltersComponent, + SearchFilterComponent, + SearchFacetFilterComponent, + SearchLabelsComponent, + SearchLabelComponent, + SearchFacetFilterComponent, + SearchFacetFilterWrapperComponent, + SearchRangeFilterComponent, + SearchTextFilterComponent, + SearchHierarchyFilterComponent, + SearchBooleanFilterComponent, + SearchFacetOptionComponent, + SearchFacetSelectedOptionComponent, + SearchFacetRangeOptionComponent, + SearchSwitchConfigurationComponent, + SearchAuthorityFilterComponent, + PageSizeSelectorComponent, CommunitySearchResultGridElementComponent, CollectionSearchResultGridElementComponent, ListableObjectComponentLoaderComponent, @@ -276,10 +328,13 @@ const COMPONENTS = [ CollectionGridElementComponent, CommunityGridElementComponent, BrowseByComponent, + AbstractTrackableComponent, + ComcolMetadataComponent, ItemTypeBadgeComponent, ItemSelectComponent, CollectionSelectComponent, - MetadataRepresentationLoaderComponent + MetadataRepresentationLoaderComponent, + SelectableListItemControlComponent ]; const ENTRY_COMPONENTS = [ @@ -303,6 +358,8 @@ const ENTRY_COMPONENTS = [ SearchResultGridElementComponent, DsDynamicListComponent, DsDynamicLookupComponent, + DsDynamicDisabledComponent, + DsDynamicLookupRelationModalComponent, DsDynamicScrollableDropdownComponent, DsDynamicTagComponent, DsDynamicTypeaheadComponent, @@ -324,7 +381,21 @@ const ENTRY_COMPONENTS = [ PlainTextMetadataListElementComponent, ItemMetadataListElementComponent, MetadataRepresentationListElementComponent, - ItemMetadataRepresentationListElementComponent + ItemMetadataRepresentationListElementComponent, + SearchResultsComponent, + CollectionSearchResultGridElementComponent, + CommunitySearchResultGridElementComponent, + SearchFacetFilterComponent, + SearchRangeFilterComponent, + SearchTextFilterComponent, + SearchHierarchyFilterComponent, + SearchBooleanFilterComponent, + SearchFacetOptionComponent, + SearchFacetSelectedOptionComponent, + SearchFacetRangeOptionComponent, + SearchAuthorityFilterComponent, + DsDynamicLookupRelationSearchTabComponent, + DsDynamicLookupRelationSelectionTabComponent ]; const SHARED_ITEM_PAGE_COMPONENTS = [ @@ -335,6 +406,7 @@ const SHARED_ITEM_PAGE_COMPONENTS = [ const PROVIDERS = [ TruncatableService, MockAdminGuard, + AbstractTrackableComponent, { provide: DYNAMIC_FORM_CONTROL_MAP_FN, useValue: dsDynamicFormControlMapFn diff --git a/src/app/shared/sidebar/search-sidebar.effects.spec.ts b/src/app/shared/sidebar/search-sidebar.effects.spec.ts deleted file mode 100644 index da675a38ce..0000000000 --- a/src/app/shared/sidebar/search-sidebar.effects.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs'; -import { provideMockActions } from '@ngrx/effects/testing'; -import { cold, hot } from 'jasmine-marbles'; -import * as fromRouter from '@ngrx/router-store'; -import { SidebarCollapseAction } from './sidebar.actions'; -import { SidebarEffects } from './sidebar-effects.service'; - -describe('SidebarEffects', () => { - let sidebarEffects: SidebarEffects; - let actions: Observable; - const dummyURL = 'http://f4fb15e2-1bd3-4e63-8d0d-486ad8bc714a'; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - SidebarEffects, - provideMockActions(() => actions), - // other providers - ], - }); - - sidebarEffects = TestBed.get(SidebarEffects); - }); - - describe('routeChange$', () => { - - it('should return a COLLAPSE action in response to an UPDATE_LOCATION action to a new route', () => { - actions = hot('--a-', { a: { type: fromRouter.ROUTER_NAVIGATION, payload: {routerState: {url: dummyURL}} } }); - - const expected = cold('--b-', { b: new SidebarCollapseAction() }); - - expect(sidebarEffects.routeChange$).toBeObservable(expected); - }); - }); -}); diff --git a/src/app/shared/trackable/abstract-trackable.component.spec.ts b/src/app/shared/trackable/abstract-trackable.component.spec.ts new file mode 100644 index 0000000000..3755092263 --- /dev/null +++ b/src/app/shared/trackable/abstract-trackable.component.spec.ts @@ -0,0 +1,101 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { AbstractTrackableComponent } from './abstract-trackable.component'; +import { INotification, Notification } from '../notifications/models/notification.model'; +import { NotificationType } from '../notifications/models/notification-type'; +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; + +describe('AbstractTrackableComponent', () => { + let comp: AbstractTrackableComponent; + let fixture: ComponentFixture; + let objectUpdatesService; + let scheduler: TestScheduler; + + const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); + const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); + const successNotification: INotification = new Notification('id', NotificationType.Success, 'success'); + + const notificationsService = jasmine.createSpyObj('notificationsService', + { + info: infoNotification, + warning: warningNotification, + success: successNotification + } + ); + + const url = 'http://test-url.com/test-url'; + + beforeEach(async(() => { + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + saveAddFieldUpdate: {}, + discardFieldUpdates: {}, + reinstateFieldUpdates: observableOf(true), + initialize: {}, + hasUpdates: observableOf(true), + isReinstatable: observableOf(false), // should always return something --> its in ngOnInit + isValidPage: observableOf(true) + } + ); + + scheduler = getTestScheduler(); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [AbstractTrackableComponent], + providers: [ + {provide: ObjectUpdatesService, useValue: objectUpdatesService}, + {provide: NotificationsService, useValue: notificationsService}, + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AbstractTrackableComponent); + comp = fixture.componentInstance; + comp.url = url; + + fixture.detectChanges(); + }); + + it('should discard object updates', () => { + comp.discard(); + + expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification); + }); + it('should undo the discard of object updates', () => { + comp.reinstate(); + + expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url); + }); + + describe('isReinstatable', () => { + beforeEach(() => { + objectUpdatesService.isReinstatable.and.returnValue(observableOf(true)); + }); + + it('should return an observable that emits true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.isReinstatable()).toBe(expected, {a: true}); + }); + }); + + describe('hasChanges', () => { + beforeEach(() => { + objectUpdatesService.hasUpdates.and.returnValue(observableOf(true)); + }); + + it('should return an observable that emits true', () => { + const expected = '(a|)'; + scheduler.expectObservable(comp.hasChanges()).toBe(expected, {a: true}); + }); + }); + +}); diff --git a/src/app/shared/trackable/abstract-trackable.component.ts b/src/app/shared/trackable/abstract-trackable.component.ts new file mode 100644 index 0000000000..cd1b425f10 --- /dev/null +++ b/src/app/shared/trackable/abstract-trackable.component.ts @@ -0,0 +1,78 @@ +import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { Component } from '@angular/core'; + +/** + * Abstract Component that is able to track changes made in the inheriting component using the ObjectUpdateService + */ +@Component({ + selector: 'ds-abstract-trackable', + template: '' +}) +export class AbstractTrackableComponent { + + /** + * The time span for being able to undo discarding changes + */ + public discardTimeOut: number; + public message: string; + public url: string; + public notificationsPrefix = 'static-pages.form.notification'; + + constructor( + public objectUpdatesService: ObjectUpdatesService, + public notificationsService: NotificationsService, + public translateService: TranslateService, + ) { + + } + + /** + * Request the object updates service to discard all current changes to this item + * Shows a notification to remind the user that they can undo this + */ + discard() { + const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), {timeOut: this.discardTimeOut}); + this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification); + } + + /** + * Request the object updates service to undo discarding all changes to this item + */ + reinstate() { + this.objectUpdatesService.reinstateFieldUpdates(this.url); + } + + /** + * Checks whether or not the object is currently reinstatable + */ + isReinstatable(): Observable { + return this.objectUpdatesService.isReinstatable(this.url); + } + + /** + * Checks whether or not there are currently updates for this object + */ + hasChanges(): Observable { + return this.objectUpdatesService.hasUpdates(this.url); + } + + /** + * Get translated notification title + * @param key + */ + private getNotificationTitle(key: string) { + return this.translateService.instant(this.notificationsPrefix + key + '.title'); + } + + /** + * Get translated notification content + * @param key + */ + private getNotificationContent(key: string) { + return this.translateService.instant(this.notificationsPrefix + key + '.content'); + + } +} diff --git a/src/app/shared/uploader/uploader-options.model.ts b/src/app/shared/uploader/uploader-options.model.ts index 0bd6412b17..f195b0930e 100644 --- a/src/app/shared/uploader/uploader-options.model.ts +++ b/src/app/shared/uploader/uploader-options.model.ts @@ -1,3 +1,4 @@ +import { RestRequestMethod } from '../../core/data/rest-request-method'; export class UploaderOptions { /** @@ -9,5 +10,15 @@ export class UploaderOptions { disableMultipart = false; - itemAlias: string; + itemAlias: string = null; + + /** + * Automatically send out an upload request when adding files + */ + autoUpload = true; + + /** + * The request method to use for the file upload request + */ + method: RestRequestMethod = RestRequestMethod.POST; } diff --git a/src/app/shared/uploader/uploader.component.html b/src/app/shared/uploader/uploader.component.html index 9d994313c6..36078fbeb4 100644 --- a/src/app/shared/uploader/uploader.component.html +++ b/src/app/shared/uploader/uploader.component.html @@ -19,23 +19,24 @@ (fileOver)="fileOverBase($event)" class="well ds-base-drop-zone mt-1 mb-3 text-muted">

- {{dropMsg | translate}} {{'uploader.or' | translate}} - - + {{dropMsg | translate}} {{'uploader.or' | translate}} +

- {{'uploader.queue-length' | translate}}: {{ uploader?.queue?.length }} | {{ uploader?.queue[0]?.file.name }} + + {{'uploader.queue-length' | translate}}: {{ uploader?.queue?.length }} | {{ uploader?.queue[0]?.file.name }} +
- {{ uploader.progress }}% + {{ uploader.progress }}% {{'uploader.processing' | translate}}...
diff --git a/src/app/shared/uploader/uploader.component.spec.ts b/src/app/shared/uploader/uploader.component.spec.ts index a36bd7241b..dcdac911bf 100644 --- a/src/app/shared/uploader/uploader.component.spec.ts +++ b/src/app/shared/uploader/uploader.component.spec.ts @@ -64,12 +64,12 @@ describe('Chips component', () => { template: `` }) class TestComponent { - public uploadFilesOptions: UploaderOptions = { + public uploadFilesOptions: UploaderOptions = Object.assign(new UploaderOptions(), { url: 'http://test', authToken: null, disableMultipart: false, itemAlias: null - }; + }); /* tslint:disable:no-empty */ public onBeforeUpload = () => { diff --git a/src/app/shared/uploader/uploader.component.ts b/src/app/shared/uploader/uploader.component.ts index ad52f4a93f..935d196d08 100644 --- a/src/app/shared/uploader/uploader.component.ts +++ b/src/app/shared/uploader/uploader.component.ts @@ -95,7 +95,8 @@ export class UploaderComponent { disableMultipart: this.uploadFilesOptions.disableMultipart, itemAlias: this.uploadFilesOptions.itemAlias, removeAfterUpload: true, - autoUpload: true + autoUpload: this.uploadFilesOptions.autoUpload, + method: this.uploadFilesOptions.method }); if (isUndefined(this.enableDragOverDocument)) { @@ -117,7 +118,10 @@ export class UploaderComponent { if (isUndefined(this.onBeforeUpload)) { this.onBeforeUpload = () => {return}; } - this.uploader.onBeforeUploadItem = () => { + this.uploader.onBeforeUploadItem = (item) => { + if (item.url !== this.uploader.options.url) { + item.url = this.uploader.options.url; + } this.onBeforeUpload(); this.isOverDocumentDropZone = observableOf(false); diff --git a/src/app/shared/utils/relation-query.utils.spec.ts b/src/app/shared/utils/relation-query.utils.spec.ts new file mode 100644 index 0000000000..f70e904422 --- /dev/null +++ b/src/app/shared/utils/relation-query.utils.spec.ts @@ -0,0 +1,18 @@ +import { getFilterByRelation, getQueryByRelations } from './relation-query.utils'; + +describe('Relation Query Utils', () => { + const relationtype = 'isAuthorOfPublication'; + const itemUUID = 'a7939af0-36ad-430d-af09-7be8b0a4dadd'; + describe('getQueryByRelations', () => { + it('Should return the correct query based on relationtype and uuid', () => { + const result = getQueryByRelations(relationtype, itemUUID); + expect(result).toEqual('query=relation.isAuthorOfPublication:a7939af0-36ad-430d-af09-7be8b0a4dadd'); + }); + }); + describe('getFilterByRelation', () => { + it('Should return the correct query based on relationtype and uuid', () => { + const result = getFilterByRelation(relationtype, itemUUID); + expect(result).toEqual('f.isAuthorOfPublication=a7939af0-36ad-430d-af09-7be8b0a4dadd'); + }); + }); +}); diff --git a/src/app/shared/utils/relation-query.utils.ts b/src/app/shared/utils/relation-query.utils.ts new file mode 100644 index 0000000000..74f9e64cc9 --- /dev/null +++ b/src/app/shared/utils/relation-query.utils.ts @@ -0,0 +1,18 @@ +/** + * Get the query for looking up items by relation type + * @param {string} relationType Relation type + * @param {string} itemUUID Item UUID + * @returns {string} Query + */ +export function getQueryByRelations(relationType: string, itemUUID: string): string { + return `query=relation.${relationType}:${itemUUID}`; +} + +/** + * Get the filter for a relation with the item's UUID + * @param relationType The type of relation e.g. 'isAuthorOfPublication' + * @param itemUUID The item's UUID + */ +export function getFilterByRelation(relationType: string, itemUUID: string): string { + return `f.${relationType}=${itemUUID}`; +} diff --git a/src/app/shared/utils/route.utils.spec.ts b/src/app/shared/utils/route.utils.spec.ts new file mode 100644 index 0000000000..610fd8756d --- /dev/null +++ b/src/app/shared/utils/route.utils.spec.ts @@ -0,0 +1,22 @@ +import { currentPath } from './route.utils'; + +describe('Route Utils', () => { + const urlTree = { + root: { + children: { + primary: { + segments: [ + { path: 'test' }, + { path: 'path' } + ] + } + + } + } + }; + const router = { parseUrl: () => urlTree } as any; + it('Should return the correct current path based on the router', () => { + const result = currentPath(router); + expect(result).toEqual('/test/path'); + }); + }); diff --git a/src/app/shared/utils/route.utils.ts b/src/app/shared/utils/route.utils.ts new file mode 100644 index 0000000000..6510fb8894 --- /dev/null +++ b/src/app/shared/utils/route.utils.ts @@ -0,0 +1,10 @@ +import { Router } from '@angular/router'; + +/** + * Util function to retrieve the current path (without query parameters) the user is on + * @param router The router service + */ +export function currentPath(router: Router) { + const urlTree = router.parseUrl(router.url); + return '/' + urlTree.root.children.primary.segments.map((it) => it.path).join('/') +} diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts index cc0175231e..146ba2e3c0 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts @@ -6,7 +6,7 @@ import { RouterTestingModule } from '@angular/router/testing'; import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { SearchService } from '../../+search-page/search-service/search.service'; +import { SearchService } from '../../core/shared/search/search.service'; import { ViewModeSwitchComponent } from './view-mode-switch.component'; import { SearchServiceStub } from '../testing/search-service-stub'; import { ViewMode } from '../../core/shared/view-mode.model'; diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.ts b/src/app/shared/view-mode-switch/view-mode-switch.component.ts index d406573646..4feb8927c2 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.ts +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.ts @@ -2,9 +2,11 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { Subscription } from 'rxjs'; -import { SearchService } from '../../+search-page/search-service/search.service'; +import { SearchService } from '../../core/shared/search/search.service'; import { ViewMode } from '../../core/shared/view-mode.model'; import { isEmpty } from '../empty.util'; +import { currentPath } from '../utils/route.utils'; +import { Router } from '@angular/router'; /** * Component to switch between list and grid views. @@ -33,7 +35,7 @@ export class ViewModeSwitchComponent implements OnInit, OnDestroy { viewModeEnum = ViewMode; private sub: Subscription; - constructor(private searchService: SearchService) { + constructor(private searchService: SearchService, private router: Router) { } /** @@ -76,7 +78,7 @@ export class ViewModeSwitchComponent implements OnInit, OnDestroy { */ public getSearchLink(): string { if (this.inPlaceSearch) { - return './'; + return currentPath(this.router); } return this.searchService.getSearchLink(); } diff --git a/src/app/statistics/angulartics/dspace-provider.spec.ts b/src/app/statistics/angulartics/dspace-provider.spec.ts new file mode 100644 index 0000000000..d89d2d9fc6 --- /dev/null +++ b/src/app/statistics/angulartics/dspace-provider.spec.ts @@ -0,0 +1,26 @@ +import { Angulartics2DSpace } from './dspace-provider'; +import { Angulartics2 } from 'angulartics2'; +import { StatisticsService } from '../statistics.service'; +import { filter } from 'rxjs/operators'; +import { of as observableOf } from 'rxjs'; + +describe('Angulartics2DSpace', () => { + let provider:Angulartics2DSpace; + let angulartics2:Angulartics2; + let statisticsService:jasmine.SpyObj; + + beforeEach(() => { + angulartics2 = { + eventTrack: observableOf({action: 'pageView', properties: {object: 'mock-object'}}), + filterDeveloperMode: () => filter(() => true) + } as any; + statisticsService = jasmine.createSpyObj('statisticsService', {trackViewEvent: null}); + provider = new Angulartics2DSpace(angulartics2, statisticsService); + }); + + it('should use the statisticsService', () => { + provider.startTracking(); + expect(statisticsService.trackViewEvent).toHaveBeenCalledWith('mock-object'); + }); + +}); diff --git a/src/app/statistics/angulartics/dspace-provider.ts b/src/app/statistics/angulartics/dspace-provider.ts new file mode 100644 index 0000000000..9ab01f6023 --- /dev/null +++ b/src/app/statistics/angulartics/dspace-provider.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { Angulartics2 } from 'angulartics2'; +import { StatisticsService } from '../statistics.service'; + +/** + * Angulartics2DSpace is a angulartics2 plugin that provides DSpace with the events. + */ +@Injectable({providedIn: 'root'}) +export class Angulartics2DSpace { + + constructor( + private angulartics2:Angulartics2, + private statisticsService:StatisticsService, + ) { + } + + /** + * Activates this plugin + */ + startTracking():void { + this.angulartics2.eventTrack + .pipe(this.angulartics2.filterDeveloperMode()) + .subscribe((event) => this.eventTrack(event)); + } + + private eventTrack(event) { + if (event.action === 'pageView') { + this.statisticsService.trackViewEvent(event.properties.object); + } else if (event.action === 'search') { + this.statisticsService.trackSearchEvent( + event.properties.searchOptions, + event.properties.page, + event.properties.sort, + event.properties.filters + ); + } + } +} diff --git a/src/app/statistics/angulartics/dspace/view-tracker.component.html b/src/app/statistics/angulartics/dspace/view-tracker.component.html new file mode 100644 index 0000000000..c0c0ffe181 --- /dev/null +++ b/src/app/statistics/angulartics/dspace/view-tracker.component.html @@ -0,0 +1 @@ +  diff --git a/src/app/statistics/angulartics/dspace/view-tracker.component.scss b/src/app/statistics/angulartics/dspace/view-tracker.component.scss new file mode 100644 index 0000000000..c76cafbe44 --- /dev/null +++ b/src/app/statistics/angulartics/dspace/view-tracker.component.scss @@ -0,0 +1,3 @@ +:host { + display: none +} diff --git a/src/app/statistics/angulartics/dspace/view-tracker.component.ts b/src/app/statistics/angulartics/dspace/view-tracker.component.ts new file mode 100644 index 0000000000..1151287ea8 --- /dev/null +++ b/src/app/statistics/angulartics/dspace/view-tracker.component.ts @@ -0,0 +1,27 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Angulartics2 } from 'angulartics2'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; + +/** + * This component triggers a page view statistic + */ +@Component({ + selector: 'ds-view-tracker', + styleUrls: ['./view-tracker.component.scss'], + templateUrl: './view-tracker.component.html', +}) +export class ViewTrackerComponent implements OnInit { + @Input() object:DSpaceObject; + + constructor( + public angulartics2:Angulartics2 + ) { + } + + ngOnInit():void { + this.angulartics2.eventTrack.next({ + action: 'pageView', + properties: {object: this.object}, + }); + } +} diff --git a/src/app/statistics/statistics.module.ts b/src/app/statistics/statistics.module.ts new file mode 100644 index 0000000000..a67ff7613c --- /dev/null +++ b/src/app/statistics/statistics.module.ts @@ -0,0 +1,36 @@ +import { ModuleWithProviders, NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CoreModule } from '../core/core.module'; +import { SharedModule } from '../shared/shared.module'; +import { ViewTrackerComponent } from './angulartics/dspace/view-tracker.component'; +import { StatisticsService } from './statistics.service'; + +@NgModule({ + imports: [ + CommonModule, + CoreModule.forRoot(), + SharedModule, + ], + declarations: [ + ViewTrackerComponent, + ], + exports: [ + ViewTrackerComponent, + ], + providers: [ + StatisticsService + ] +}) +/** + * This module handles the statistics + */ +export class StatisticsModule { + static forRoot():ModuleWithProviders { + return { + ngModule: StatisticsModule, + providers: [ + StatisticsService + ] + }; + } +} diff --git a/src/app/statistics/statistics.service.spec.ts b/src/app/statistics/statistics.service.spec.ts new file mode 100644 index 0000000000..c6cc4c10b5 --- /dev/null +++ b/src/app/statistics/statistics.service.spec.ts @@ -0,0 +1,144 @@ +import { StatisticsService } from './statistics.service'; +import { RequestService } from '../core/data/request.service'; +import { HALEndpointServiceStub } from '../shared/testing/hal-endpoint-service-stub'; +import { getMockRequestService } from '../shared/mocks/mock-request.service'; +import { TrackRequest } from './track-request.model'; +import { isEqual } from 'lodash'; +import { DSpaceObjectType } from '../core/shared/dspace-object-type.model'; +import { SearchOptions } from '../shared/search/search-options.model'; + +describe('StatisticsService', () => { + let service:StatisticsService; + let requestService:jasmine.SpyObj; + const restURL = 'https://rest.api'; + const halService:any = new HALEndpointServiceStub(restURL); + + function initTestService() { + return new StatisticsService( + requestService, + halService, + ); + } + + describe('trackViewEvent', () => { + requestService = getMockRequestService(); + service = initTestService(); + + it('should send a request to track an item view ', () => { + const mockItem:any = {uuid: 'mock-item-uuid', type: 'item'}; + service.trackViewEvent(mockItem); + const request:TrackRequest = requestService.configure.calls.mostRecent().args[0]; + expect(request.body).toBeDefined('request.body'); + const body = JSON.parse(request.body); + expect(body.targetId).toBe('mock-item-uuid'); + expect(body.targetType).toBe('item'); + }); + }); + + describe('trackSearchEvent', () => { + requestService = getMockRequestService(); + service = initTestService(); + + const mockSearch:any = new SearchOptions({ + query: 'mock-query', + }); + + const page = { + size: 10, + totalElements: 248, + totalPages: 25, + number: 4 + }; + const sort = {by: 'search-field', order: 'ASC'}; + service.trackSearchEvent(mockSearch, page, sort); + const request:TrackRequest = requestService.configure.calls.mostRecent().args[0]; + const body = JSON.parse(request.body); + + it('should specify the right query', () => { + expect(body.query).toBe('mock-query'); + }); + + it('should specify the pagination info', () => { + expect(body.page).toEqual({ + size: 10, + totalElements: 248, + totalPages: 25, + number: 4 + }); + }); + + it('should specify the sort options', () => { + expect(body.sort).toEqual({ + by: 'search-field', + order: 'asc' + }); + }); + }); + + describe('trackSearchEvent with optional parameters', () => { + requestService = getMockRequestService(); + service = initTestService(); + + const mockSearch:any = new SearchOptions({ + query: 'mock-query', + configuration: 'mock-configuration', + dsoType: DSpaceObjectType.ITEM, + scope: 'mock-scope' + }); + + const page = { + size: 10, + totalElements: 248, + totalPages: 25, + number: 4 + }; + const sort = {by: 'search-field', order: 'ASC'}; + const filters = [ + { + filter: 'title', + operator: 'notcontains', + value: 'dolor sit', + label: 'dolor sit' + }, + { + filter: 'author', + operator: 'authority', + value: '9zvxzdm4qru17or5a83wfgac', + label: 'Amet, Consectetur' + } + ]; + service.trackSearchEvent(mockSearch, page, sort, filters); + const request:TrackRequest = requestService.configure.calls.mostRecent().args[0]; + const body = JSON.parse(request.body); + + it('should specify the dsoType', () => { + expect(body.dsoType).toBe('item'); + }); + + it('should specify the scope', () => { + expect(body.scope).toBe('mock-scope'); + }); + + it('should specify the configuration', () => { + expect(body.configuration).toBe('mock-configuration'); + }); + + it('should specify the filters', () => { + expect(isEqual(body.appliedFilters, [ + { + filter: 'title', + operator: 'notcontains', + value: 'dolor sit', + label: 'dolor sit' + }, + { + filter: 'author', + operator: 'authority', + value: '9zvxzdm4qru17or5a83wfgac', + label: 'Amet, Consectetur' + } + ])).toBe(true); + }); + }); + +}); diff --git a/src/app/statistics/statistics.service.ts b/src/app/statistics/statistics.service.ts new file mode 100644 index 0000000000..004e013164 --- /dev/null +++ b/src/app/statistics/statistics.service.ts @@ -0,0 +1,93 @@ +import { RequestService } from '../core/data/request.service'; +import { Injectable } from '@angular/core'; +import { DSpaceObject } from '../core/shared/dspace-object.model'; +import { map, take } from 'rxjs/operators'; +import { TrackRequest } from './track-request.model'; +import { hasValue, isNotEmpty } from '../shared/empty.util'; +import { HALEndpointService } from '../core/shared/hal-endpoint.service'; +import { RestRequest } from '../core/data/request.models'; +import { SearchOptions } from '../shared/search/search-options.model'; + +/** + * The statistics service + */ +@Injectable() +export class StatisticsService { + + constructor( + protected requestService: RequestService, + protected halService: HALEndpointService, + ) { + } + + private sendEvent(linkPath: string, body: any) { + const requestId = this.requestService.generateRequestId(); + this.halService.getEndpoint(linkPath).pipe( + map((endpoint: string) => new TrackRequest(requestId, endpoint, JSON.stringify(body))), + take(1) // otherwise the previous events will fire again + ).subscribe((request: RestRequest) => this.requestService.configure(request)); + } + + /** + * To track a page view + * @param dso: The dso which was viewed + */ + trackViewEvent(dso: DSpaceObject) { + this.sendEvent('/statistics/viewevents', { + targetId: dso.uuid, + targetType: (dso as any).type + }); + } + + /** + * To track a search + * @param searchOptions: The query, scope, dsoType and configuration of the search. Filters from this object are ignored in favor of the filters parameter of this method. + * @param page: An object that describes the pagination status + * @param sort: An object that describes the sort status + * @param filters: An array of search filters used to filter the result set + */ + trackSearchEvent( + searchOptions: SearchOptions, + page: { size: number, totalElements: number, totalPages: number, number: number }, + sort: { by: string, order: string }, + filters?: Array<{ filter: string, operator: string, value: string, label: string }> + ) { + const body = { + query: searchOptions.query, + page: { + size: page.size, + totalElements: page.totalElements, + totalPages: page.totalPages, + number: page.number + }, + sort: { + by: sort.by, + order: sort.order.toLowerCase() + }, + }; + if (hasValue(searchOptions.configuration)) { + Object.assign(body, { configuration: searchOptions.configuration }) + } + if (hasValue(searchOptions.dsoType)) { + Object.assign(body, { dsoType: searchOptions.dsoType.toLowerCase() }) + } + if (hasValue(searchOptions.scope)) { + Object.assign(body, { scope: searchOptions.scope }) + } + if (isNotEmpty(filters)) { + const bodyFilters = []; + for (let i = 0, arrayLength = filters.length; i < arrayLength; i++) { + const filter = filters[i]; + bodyFilters.push({ + filter: filter.filter, + operator: filter.operator, + value: filter.value, + label: filter.label + }) + } + Object.assign(body, { appliedFilters: bodyFilters }) + } + this.sendEvent('/statistics/searchevents', body); + } + +} diff --git a/src/app/statistics/track-request.model.ts b/src/app/statistics/track-request.model.ts new file mode 100644 index 0000000000..770d3146c6 --- /dev/null +++ b/src/app/statistics/track-request.model.ts @@ -0,0 +1,11 @@ +import { ResponseParsingService } from '../core/data/parsing.service'; +import { PostRequest } from '../core/data/request.models'; +import { StatusCodeOnlyResponseParsingService } from '../core/data/status-code-only-response-parsing.service'; +import { GenericConstructor } from '../core/shared/generic-constructor'; + +export class TrackRequest extends PostRequest { + + getResponseParser(): GenericConstructor { + return StatusCodeOnlyResponseParsingService; + } +} diff --git a/src/app/submission/form/collection/submission-form-collection.component.spec.ts b/src/app/submission/form/collection/submission-form-collection.component.spec.ts index b95a140b46..454e3f6d75 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.spec.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.spec.ts @@ -19,8 +19,8 @@ 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 { Community } from '../../../core/shared/community.model'; import { PaginatedList } from '../../../core/data/paginated-list'; import { PageInfo } from '../../../core/shared/page-info.model'; import { Collection } from '../../../core/shared/collection.model'; diff --git a/src/app/submission/form/collection/submission-form-collection.component.ts b/src/app/submission/form/collection/submission-form-collection.component.ts index 79d2f2a7bc..d318bfe687 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -1,28 +1,8 @@ -import { - ChangeDetectorRef, - Component, - EventEmitter, - HostListener, - Input, - OnChanges, - OnInit, - Output, - SimpleChanges -} from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, HostListener, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; import { FormControl } from '@angular/forms'; import { BehaviorSubject, combineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; -import { - debounceTime, - distinctUntilChanged, - filter, - find, - flatMap, - map, - mergeMap, - reduce, - startWith -} from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, filter, map, mergeMap, reduce, startWith, flatMap, find } from 'rxjs/operators'; import { Collection } from '../../../core/shared/collection.model'; import { CommunityDataService } from '../../../core/data/community-data.service'; @@ -36,7 +16,7 @@ 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 { CollectionDataService } from '../../../core/data/collection-data.service'; -import { FindAllOptions } from '../../../core/data/request.models'; +import { FindListOptions } from '../../../core/data/request.models'; /** * An interface to represent a collection entry @@ -205,7 +185,7 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { map((collectionRD: RemoteData) => collectionRD.payload.name) ); - const findOptions: FindAllOptions = { + const findOptions: FindListOptions = { elementsPerPage: 1000 }; @@ -247,7 +227,8 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit { } else { return listCollection.filter((v) => v.collection.name.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1).slice(0, 5); } - })); + }) + ); } } } diff --git a/src/app/submission/form/submission-form.component.ts b/src/app/submission/form/submission-form.component.ts index b592972839..3ea07f9ae7 100644 --- a/src/app/submission/form/submission-form.component.ts +++ b/src/app/submission/form/submission-form.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { of as observableOf, Observable, Subscription } from 'rxjs'; -import { distinctUntilChanged, filter, flatMap, map } from 'rxjs/operators'; +import { distinctUntilChanged, filter, flatMap, map, switchMap } from 'rxjs/operators'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { SubmissionObjectEntry } from '../objects/submission-objects.reducer'; @@ -77,12 +77,7 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { * The uploader configuration options * @type {UploaderOptions} */ - public uploadFilesOptions: UploaderOptions = { - url: '', - authToken: null, - disableMultipart: false, - itemAlias: null - }; + public uploadFilesOptions: UploaderOptions = new UploaderOptions(); /** * A boolean representing if component is active @@ -125,7 +120,7 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { map((submission: SubmissionObjectEntry) => submission.isLoading), map((isLoading: boolean) => isLoading), distinctUntilChanged(), - flatMap((isLoading: boolean) => { + switchMap((isLoading: boolean) => { if (!isLoading) { return this.getSectionsList(); } else { diff --git a/src/app/submission/form/submission-upload-files/submission-upload-files.component.spec.ts b/src/app/submission/form/submission-upload-files/submission-upload-files.component.spec.ts index 60a572df54..34d291f0e4 100644 --- a/src/app/submission/form/submission-upload-files/submission-upload-files.component.spec.ts +++ b/src/app/submission/form/submission-upload-files/submission-upload-files.component.spec.ts @@ -28,6 +28,7 @@ import { SubmissionJsonPatchOperationsServiceStub } from '../../../shared/testin import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service'; import { SharedModule } from '../../../shared/shared.module'; import { createTestComponent } from '../../../shared/testing/utils'; +import { UploaderOptions } from '../../../shared/uploader/uploader-options.model'; describe('SubmissionUploadFilesComponent Component', () => { @@ -112,12 +113,12 @@ describe('SubmissionUploadFilesComponent Component', () => { comp.submissionId = submissionId; comp.collectionId = collectionId; comp.sectionId = 'upload'; - comp.uploadFilesOptions = { + comp.uploadFilesOptions = Object.assign(new UploaderOptions(),{ url: '', authToken: null, disableMultipart: false, itemAlias: null - }; + }); }); @@ -208,11 +209,11 @@ class TestComponent { submissionId = mockSubmissionId; collectionId = mockSubmissionCollectionId; sectionId = 'upload'; - uploadFilesOptions = { + uploadFilesOptions = Object.assign(new UploaderOptions(), { url: '', authToken: null, disableMultipart: false, itemAlias: null - }; + }); } diff --git a/src/app/submission/sections/form/section-form.component.spec.ts b/src/app/submission/sections/form/section-form.component.spec.ts index eb56a92113..be13c14941 100644 --- a/src/app/submission/sections/form/section-form.component.spec.ts +++ b/src/app/submission/sections/form/section-form.component.spec.ts @@ -43,6 +43,9 @@ import { SubmissionSectionError } from '../../objects/submission-objects.reducer import { DynamicFormControlEvent, DynamicFormControlEventType } from '@ng-dynamic-forms/core'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { FormRowModel } from '../../../core/config/models/config-submission-form.model'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; function getMockSubmissionFormsConfigService(): SubmissionFormsConfigService { return jasmine.createSpyObj('FormOperationsService', { @@ -115,11 +118,11 @@ const testFormConfiguration = { const testFormModel = [ new DynamicRowGroupModel({ id: 'df-row-group-config-1', - group: [new DsDynamicInputModel({ id: 'dc.title' })], + group: [new DsDynamicInputModel({ id: 'dc.title', metadataFields: [], repeatable: false, submissionId: '1234' })], }), new DynamicRowGroupModel({ id: 'df-row-group-config-2', - group: [new DsDynamicInputModel({ id: 'dc.contributor' })], + group: [new DsDynamicInputModel({ id: 'dc.contributor', metadataFields: [], repeatable: false, submissionId: '1234' })], }) ]; @@ -179,6 +182,7 @@ describe('SubmissionSectionformComponent test suite', () => { { provide: 'collectionIdProvider', useValue: collectionId }, { provide: 'sectionDataProvider', useValue: sectionObject }, { provide: 'submissionIdProvider', useValue: submissionId }, + { provide: WorkspaceitemDataService, useValue: {findById: () => observableOf(new RemoteData(false, false, true, null, new WorkspaceItem()))}}, ChangeDetectorRef, SubmissionSectionformComponent ], diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 2269ccd5f1..49dbaea807 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -15,7 +15,10 @@ import { hasValue, isNotEmpty, isUndefined } from '../../../shared/empty.util'; import { ConfigData } from '../../../core/config/config-data'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; -import { SubmissionSectionError, SubmissionSectionObject } from '../../objects/submission-objects.reducer'; +import { + SubmissionSectionError, + SubmissionSectionObject +} from '../../objects/submission-objects.reducer'; import { FormFieldPreviousValueObject } from '../../../shared/form/builder/models/form-field-previous-value-object'; import { GLOBAL_CONFIG } from '../../../../config'; import { GlobalConfig } from '../../../../config/global-config.interface'; @@ -28,6 +31,11 @@ import { NotificationsService } from '../../../shared/notifications/notification import { SectionsService } from '../sections.service'; import { difference } from '../../../shared/object.util'; import { WorkspaceitemSectionFormObject } from '../../../core/submission/models/workspaceitem-section-form.model'; +import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { combineLatest as combineLatestObservable } from 'rxjs'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { RemoteData } from '../../../core/data/remote-data'; /** * This component represents a section that contains a Form. @@ -100,6 +108,7 @@ export class SubmissionSectionformComponent extends SectionModelComponent { */ protected subs: Subscription[] = []; + protected workspaceItem: WorkspaceItem; /** * The FormComponent reference */ @@ -131,6 +140,7 @@ export class SubmissionSectionformComponent extends SectionModelComponent { protected sectionService: SectionsService, protected submissionService: SubmissionService, protected translate: TranslateService, + protected workspaceItemDataService: WorkspaceitemDataService, @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, @Inject('collectionIdProvider') public injectedCollectionId: string, @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, @@ -144,15 +154,19 @@ export class SubmissionSectionformComponent extends SectionModelComponent { onSectionInit() { this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id); this.formId = this.formService.getUniqueId(this.sectionData.id); - this.formConfigService.getConfigByHref(this.sectionData.config).pipe( map((configData: ConfigData) => configData.payload), tap((config: SubmissionFormsModel) => this.formConfig = config), - flatMap(() => this.sectionService.getSectionData(this.submissionId, this.sectionData.id)), + flatMap(() => + combineLatestObservable( + this.sectionService.getSectionData(this.submissionId, this.sectionData.id), + this.workspaceItemDataService.findById(this.submissionId).pipe(getSucceededRemoteData(), map((wsiRD: RemoteData) => wsiRD.payload)) + )), take(1)) - .subscribe((sectionData: WorkspaceitemSectionFormObject) => { + .subscribe(([sectionData, workspaceItem]: [WorkspaceitemSectionFormObject, WorkspaceItem]) => { if (isUndefined(this.formModel)) { this.sectionData.errors = []; + this.workspaceItem = workspaceItem; // Is the first loading so init form this.initForm(sectionData); this.sectionData.data = sectionData; @@ -215,16 +229,19 @@ export class SubmissionSectionformComponent extends SectionModelComponent { initForm(sectionData: WorkspaceitemSectionFormObject): void { try { this.formModel = this.formBuilderService.modelFromConfiguration( + this.submissionId, this.formConfig, this.collectionId, sectionData, - this.submissionService.getSubmissionScope()); + this.submissionService.getSubmissionScope() + ); } catch (e) { const msg: string = this.translate.instant('error.submission.sections.init-form-error') + e.toString(); const sectionError: SubmissionSectionError = { message: msg, path: '/sections/' + this.sectionData.id }; + console.error(e.stack); this.sectionService.setSectionError(this.submissionId, this.sectionData.id, sectionError); } } diff --git a/src/app/submission/sections/sections-type.ts b/src/app/submission/sections/sections-type.ts index 02e0ba478b..34ecafe42b 100644 --- a/src/app/submission/sections/sections-type.ts +++ b/src/app/submission/sections/sections-type.ts @@ -1,4 +1,5 @@ export enum SectionsType { + Relationships = 'relationships', SubmissionForm = 'submission-form', Upload = 'upload', License = 'license', diff --git a/src/app/submission/sections/sections.service.ts b/src/app/submission/sections/sections.service.ts index aed83143a5..f6ad5ef0cf 100644 --- a/src/app/submission/sections/sections.service.ts +++ b/src/app/submission/sections/sections.service.ts @@ -84,7 +84,7 @@ export class SectionsService { } else if (!isEqual(currentErrors, prevErrors)) { // compare previous error list with the current one const dispatchedErrors = []; - // Itereate over the current error list + // Iterate over the current error list currentErrors.forEach((error: SubmissionSectionError) => { const errorPaths: SectionErrorPath[] = parseSectionErrorPaths(error.path); diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts index b2575d1d58..8cf0d22d20 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts @@ -166,6 +166,7 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges { const formModel: DynamicFormControlModel[] = []; const metadataGroupModelConfig = Object.assign({}, BITSTREAM_METADATA_FORM_GROUP_CONFIG); metadataGroupModelConfig.group = this.formBuilderService.modelFromConfiguration( + this.submissionId, configForm, this.collectionId, this.fileData.metadata, diff --git a/src/app/submission/submission.service.spec.ts b/src/app/submission/submission.service.spec.ts index d67fb3679a..3a95b0747b 100644 --- a/src/app/submission/submission.service.spec.ts +++ b/src/app/submission/submission.service.spec.ts @@ -37,18 +37,16 @@ import { SaveSubmissionSectionFormAction, SetActiveSectionAction } from './objects/submission-objects.actions'; -import { RemoteData } from '../core/data/remote-data'; import { RemoteDataError } from '../core/data/remote-data-error'; import { throwError as observableThrowError } from 'rxjs/internal/observable/throwError'; import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject, - createSuccessfulRemoteDataObject$ } from '../shared/testing/utils'; import { getMockSearchService } from '../shared/mocks/mock-search-service'; import { getMockRequestService } from '../shared/mocks/mock-request.service'; -import { SearchService } from '../+search-page/search-service/search.service'; import { RequestService } from '../core/data/request.service'; +import { SearchService } from '../core/shared/search/search.service'; describe('SubmissionService test suite', () => { const config = MOCK_SUBMISSION_CONFIG; diff --git a/src/app/submission/submission.service.ts b/src/app/submission/submission.service.ts index c9be658b31..fa8024af53 100644 --- a/src/app/submission/submission.service.ts +++ b/src/app/submission/submission.service.ts @@ -28,12 +28,7 @@ import { SaveSubmissionSectionFormAction, SetActiveSectionAction } from './objects/submission-objects.actions'; -import { - SubmissionObjectEntry, - SubmissionSectionEntry, - SubmissionSectionError, - SubmissionSectionObject -} from './objects/submission-objects.reducer'; +import { SubmissionObjectEntry, SubmissionSectionEntry, SubmissionSectionError, SubmissionSectionObject } from './objects/submission-objects.reducer'; import { submissionObjectFromIdSelector } from './selectors'; import { GlobalConfig } from '../../config/global-config.interface'; import { GLOBAL_CONFIG } from '../../config'; @@ -55,8 +50,8 @@ import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../shared/testing/utils'; -import { SearchService } from '../+search-page/search-service/search.service'; import { RequestService } from '../core/data/request.service'; +import { SearchService } from '../core/shared/search/search.service'; /** * A service that provides methods used in submission process. @@ -74,6 +69,8 @@ export class SubmissionService { */ protected timer$: Observable; + private workspaceLinkPath = 'workspaceitems'; + private workflowLinkPath = 'workflowitems'; /** * Initialize service variables * @param {GlobalConfig} EnvConfig @@ -116,7 +113,7 @@ export class SubmissionService { * observable of SubmissionObject */ createSubmission(): Observable { - return this.restService.postToEndpoint('workspaceitems', {}).pipe( + return this.restService.postToEndpoint(this.workspaceLinkPath, {}).pipe( map((workspaceitem: SubmissionObject) => workspaceitem[0]), catchError(() => observableOf({}))) } @@ -134,7 +131,7 @@ export class SubmissionService { let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'text/uri-list'); options.headers = headers; - return this.restService.postToEndpoint('workflowitems', selfUrl, null, options) as Observable; + return this.restService.postToEndpoint(this.workflowLinkPath, selfUrl, null, options) as Observable; } /** @@ -328,9 +325,9 @@ export class SubmissionService { getSubmissionObjectLinkName(): string { const url = this.router.routerState.snapshot.url; if (url.startsWith('/workspaceitems') || url.startsWith('/submit')) { - return 'workspaceitems'; + return this.workspaceLinkPath; } else if (url.startsWith('/workflowitems')) { - return 'workflowitems'; + return this.workflowLinkPath; } else { return 'edititems'; } @@ -345,10 +342,10 @@ export class SubmissionService { getSubmissionScope(): SubmissionScopeType { let scope: SubmissionScopeType; switch (this.getSubmissionObjectLinkName()) { - case 'workspaceitems': + case this.workspaceLinkPath: scope = SubmissionScopeType.WorkspaceItem; break; - case 'workflowitems': + case this.workflowLinkPath: scope = SubmissionScopeType.WorkflowItem; break; } diff --git a/src/app/submission/submit/submission-submit.component.ts b/src/app/submission/submit/submission-submit.component.ts index dbfd2f5a40..448ccf97e2 100644 --- a/src/app/submission/submit/submission-submit.component.ts +++ b/src/app/submission/submit/submission-submit.component.ts @@ -56,7 +56,7 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { * * @param {ChangeDetectorRef} changeDetectorRef * @param {NotificationsService} notificationsService - * @param {SubmissionService} submissioService + * @param {SubmissionService} submissionService * @param {Router} router * @param {TranslateService} translate * @param {ViewContainerRef} viewContainerRef @@ -64,7 +64,7 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { constructor(private changeDetectorRef: ChangeDetectorRef, private notificationsService: NotificationsService, private router: Router, - private submissioService: SubmissionService, + private submissionService: SubmissionService, private translate: TranslateService, private viewContainerRef: ViewContainerRef) { } @@ -75,7 +75,7 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { ngOnInit() { // NOTE execute the code on the browser side only, otherwise it is executed twice this.subs.push( - this.submissioService.createSubmission() + this.submissionService.createSubmission() .subscribe((submissionObject: SubmissionObject) => { // NOTE new submission is created on the browser side only if (isNotNull(submissionObject)) { diff --git a/src/backend/api.ts b/src/backend/api.ts index e1943b5d30..a4763f0be7 100644 --- a/src/backend/api.ts +++ b/src/backend/api.ts @@ -117,7 +117,7 @@ export function createMockApi() { const id = req.params.item_id; try { req.item_id = id; - req.item = ITEMS.items.find((item) => { + req.itemRD$ = ITEMS.items.find((item) => { return item.id === id; }); next(); @@ -127,7 +127,7 @@ export function createMockApi() { }); router.route('/items/:item_id').get((req, res) => { - res.json(toHALResponse(req, req.item)); + res.json(toHALResponse(req, req.itemRD$)); }); router.route('/bundles').get((req, res) => { diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index ede2b53e74..87b830ee7d 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -21,6 +21,8 @@ import { AuthService } from '../../app/core/auth/auth.service'; import { Angulartics2Module } from 'angulartics2'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { SubmissionService } from '../../app/submission/submission.service'; +import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; +import { StatisticsModule } from '../../app/statistics/statistics.module'; export const REQ_KEY = makeStateKey('req'); @@ -47,7 +49,8 @@ export function getRequest(transferState: TransferState): any { preloadingStrategy: IdlePreload }), - Angulartics2Module.forRoot([Angulartics2GoogleAnalytics]), + StatisticsModule.forRoot(), + Angulartics2Module.forRoot([Angulartics2GoogleAnalytics, Angulartics2DSpace]), BrowserAnimationsModule, DSpaceBrowserTransferStateModule, TranslateModule.forRoot({ diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index 02abf6449b..44b21859bd 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -22,6 +22,8 @@ import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { AngularticsMock } from '../../app/shared/mocks/mock-angulartics.service'; import { SubmissionService } from '../../app/submission/submission.service'; import { ServerSubmissionService } from '../../app/submission/server-submission.service'; +import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; +import { Angulartics2Module } from 'angulartics2'; export function createTranslateLoader() { return new TranslateJson5UniversalLoader('dist/assets/i18n/', '.json5'); @@ -45,6 +47,7 @@ export function createTranslateLoader() { deps: [] } }), + Angulartics2Module.forRoot([Angulartics2GoogleAnalytics, Angulartics2DSpace]), ServerModule, AppModule ], @@ -53,6 +56,10 @@ export function createTranslateLoader() { provide: Angulartics2GoogleAnalytics, useClass: AngularticsMock }, + { + provide: Angulartics2DSpace, + useClass: AngularticsMock + }, { provide: AuthService, useClass: ServerAuthService diff --git a/themes/mantis/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss b/themes/mantis/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss deleted file mode 100644 index 42b8e0205b..0000000000 --- a/themes/mantis/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss +++ /dev/null @@ -1,5 +0,0 @@ -@import 'src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss'; - -::ng-deep .noUi-connect { - background: $info; -} diff --git a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html b/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html index 99d92d2af8..907f70b941 100644 --- a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html +++ b/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html @@ -4,7 +4,7 @@ diff --git a/themes/mantis/app/entity-groups/research-entities/item-pages/orgunit/orgunit.component.html b/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html similarity index 79% rename from themes/mantis/app/entity-groups/research-entities/item-pages/orgunit/orgunit.component.html rename to themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html index 15529a1bd5..ee78d9c653 100644 --- a/themes/mantis/app/entity-groups/research-entities/item-pages/orgunit/orgunit.component.html +++ b/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html @@ -53,18 +53,6 @@
- - - -
+
+
+ + +
+
diff --git a/themes/mantis/app/entity-groups/research-entities/item-pages/orgunit/orgunit.component.scss b/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.scss similarity index 86% rename from themes/mantis/app/entity-groups/research-entities/item-pages/orgunit/orgunit.component.scss rename to themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.scss index 54651aede0..4a1d2516da 100644 --- a/themes/mantis/app/entity-groups/research-entities/item-pages/orgunit/orgunit.component.scss +++ b/themes/mantis/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.scss @@ -1,4 +1,4 @@ -@import 'src/app/entity-groups/research-entities/item-pages/orgunit/orgunit.component.scss'; +@import 'src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.scss'; :host { > * { diff --git a/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html b/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html index bb5cb1b787..1679f9354d 100644 --- a/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html +++ b/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html @@ -79,7 +79,10 @@

{{"item.page.person.search.title" | translate}}

- - + +
diff --git a/themes/mantis/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html similarity index 100% rename from themes/mantis/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html rename to themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html diff --git a/themes/mantis/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html b/themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html similarity index 100% rename from themes/mantis/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html rename to themes/mantis/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html diff --git a/themes/mantis/app/+search-page/search-filters/search-filter/search-filter.component.html b/themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.html similarity index 100% rename from themes/mantis/app/+search-page/search-filters/search-filter/search-filter.component.html rename to themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.html diff --git a/themes/mantis/app/+search-page/search-filters/search-filter/search-filter.component.scss b/themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.scss similarity index 61% rename from themes/mantis/app/+search-page/search-filters/search-filter/search-filter.component.scss rename to themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.scss index 8e9b1d32b1..4d2d29ae41 100644 --- a/themes/mantis/app/+search-page/search-filters/search-filter/search-filter.component.scss +++ b/themes/mantis/app/shared/search/search-filters/search-filter/search-filter.component.scss @@ -1,4 +1,4 @@ -@import 'src/app/+search-page/search-filters/search-filter/search-filter.component.scss'; +@import 'src/app/shared/search/search-filters/search-filter/search-filter.component.scss'; .facet-filter { background-color: map-get($theme-colors, light); diff --git a/themes/mantis/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss b/themes/mantis/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss new file mode 100644 index 0000000000..7edcb8f063 --- /dev/null +++ b/themes/mantis/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss @@ -0,0 +1,5 @@ +@import 'src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.scss'; + +::ng-deep .noUi-connect { + background: $info; +} diff --git a/themes/mantis/app/+search-page/search-filters/search-filters.component.html b/themes/mantis/app/shared/search/search-filters/search-filters.component.html similarity index 100% rename from themes/mantis/app/+search-page/search-filters/search-filters.component.html rename to themes/mantis/app/shared/search/search-filters/search-filters.component.html diff --git a/themes/mantis/app/+search-page/search-settings/search-settings.component.html b/themes/mantis/app/shared/search/search-settings/search-settings.component.html similarity index 100% rename from themes/mantis/app/+search-page/search-settings/search-settings.component.html rename to themes/mantis/app/shared/search/search-settings/search-settings.component.html diff --git a/themes/mantis/app/+search-page/search-settings/search-settings.component.scss b/themes/mantis/app/shared/search/search-settings/search-settings.component.scss similarity index 65% rename from themes/mantis/app/+search-page/search-settings/search-settings.component.scss rename to themes/mantis/app/shared/search/search-settings/search-settings.component.scss index 602c8ca4c3..073039dae8 100644 --- a/themes/mantis/app/+search-page/search-settings/search-settings.component.scss +++ b/themes/mantis/app/shared/search/search-settings/search-settings.component.scss @@ -1,4 +1,4 @@ -@import 'src/app/+search-page/search-settings/search-settings.component.scss'; +@import 'src/app/shared/search/search-settings/search-settings.component.scss'; .setting-option { background-color: map-get($theme-colors, light); diff --git a/webpack/webpack.common.js b/webpack/webpack.common.js index 028815d958..e63ae024ed 100644 --- a/webpack/webpack.common.js +++ b/webpack/webpack.common.js @@ -15,6 +15,9 @@ module.exports = (env) => { let copyWebpackOptions = [{ from: path.join(__dirname, '..', 'node_modules', '@fortawesome', 'fontawesome-free', 'webfonts'), to: path.join('assets', 'fonts') + }, { + from: path.join(__dirname, '..', 'resources', 'fonts'), + to: path.join('assets', 'fonts') }, { from: path.join(__dirname, '..', 'resources', 'images'), to: path.join('assets', 'images') @@ -24,6 +27,15 @@ module.exports = (env) => { } ]; + const themeFonts = path.join(themePath, 'resources', 'fonts'); + if(theme && fs.existsSync(themeFonts)) { + copyWebpackOptions.push({ + from: themeFonts, + to: path.join('assets', 'fonts') , + force: true, + }); + } + const themeImages = path.join(themePath, 'resources', 'images'); if(theme && fs.existsSync(themeImages)) { copyWebpackOptions.push({ @@ -107,12 +119,6 @@ module.exports = (env) => { sourceMap: true } }, - { - loader: 'resolve-url-loader', - options: { - sourceMap: true - } - }, { loader: 'sass-loader', options: { @@ -120,6 +126,12 @@ module.exports = (env) => { includePaths: [projectRoot('./'), path.join(themePath, 'styles')] } }, + { + loader: 'resolve-url-loader', + options: { + sourceMap: true + } + }, { loader: 'sass-resources-loader', options: { @@ -145,23 +157,23 @@ module.exports = (env) => { sourceMap: true } }, - { - loader: 'resolve-url-loader', - options: { - sourceMap: true - } - }, { loader: 'sass-loader', options: { sourceMap: true, includePaths: [projectRoot('./'), path.join(themePath, 'styles')] } - } + }, + { + loader: 'resolve-url-loader', + options: { + sourceMap: true + } + }, ] }, { - test: /\.html$/, + test: /\.(html|eot|ttf|otf|svg|woff|woff2)$/, loader: 'raw-loader' } ] diff --git a/webpack/webpack.test.js b/webpack/webpack.test.js index 83e6e44e79..de53de31c4 100644 --- a/webpack/webpack.test.js +++ b/webpack/webpack.test.js @@ -160,12 +160,6 @@ module.exports = function (env) { sourceMap: true } }, - { - loader: 'resolve-url-loader', - options: { - sourceMap: true - } - }, { loader: 'sass-loader', options: { @@ -173,6 +167,12 @@ module.exports = function (env) { includePaths: [projectRoot('./'), path.join(themePath, 'styles')] } }, + { + loader: 'resolve-url-loader', + options: { + sourceMap: true + } + }, { loader: 'sass-resources-loader', options: { @@ -198,19 +198,19 @@ module.exports = function (env) { sourceMap: true } }, - { - loader: 'resolve-url-loader', - options: { - sourceMap: true - } - }, { loader: 'sass-loader', options: { sourceMap: true, includePaths: [projectRoot('./'), path.join(themePath, 'styles')] } - } + }, + { + loader: 'resolve-url-loader', + options: { + sourceMap: true + } + }, ] }, diff --git a/yarn.lock b/yarn.lock index 69f4a072ae..b4ec416395 100644 --- a/yarn.lock +++ b/yarn.lock @@ -35,6 +35,13 @@ dependencies: tslib "^1.9.0" +"@angular/cdk@^6.4.7": + version "6.4.7" + resolved "https://registry.yarnpkg.com/@angular/cdk/-/cdk-6.4.7.tgz#1549b304dd412e82bd854cc55a7d5c6772ee0411" + integrity sha512-18x0U66fLD5kGQWZ9n3nb75xQouXlWs7kUDaTd8HTrHpT1s2QIAqlLd1KxfrYiVhsEC2jPQaoiae7VnBlcvkBg== + dependencies: + tslib "^1.7.1" + "@angular/cli@^6.1.5": version "6.1.5" resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-6.1.5.tgz#312c062631285ff06fd07ecde8afe22cdef5a0e1" @@ -2093,15 +2100,14 @@ cliui@^5.0.0: strip-ansi "^5.2.0" wrap-ansi "^5.1.0" -clone-deep@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-2.0.2.tgz#00db3a1e173656730d1188c3d6aced6d7ea97713" - integrity sha512-SZegPTKjCgpQH63E+eN6mVEEPdQBOUzjyJm5Pora4lrwWRFS8I0QAxV/KD6vV/i0WuijHZWQC1fMsPEdxfdVCQ== +clone-deep@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" + integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ== dependencies: - for-own "^1.0.0" is-plain-object "^2.0.4" - kind-of "^6.0.0" - shallow-clone "^1.0.0" + kind-of "^6.0.2" + shallow-clone "^3.0.0" clone-stats@^0.0.1: version "0.0.1" @@ -4121,11 +4127,6 @@ font-awesome@4.7.0: resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133" integrity sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM= -for-in@^0.1.3: - version "0.1.8" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1" - integrity sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE= - for-in@^1.0.1, for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -4138,13 +4139,6 @@ for-own@^0.1.4: dependencies: for-in "^1.0.1" -for-own@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b" - integrity sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs= - dependencies: - for-in "^1.0.1" - forever-agent@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" @@ -6148,7 +6142,7 @@ loader-utils@^0.2.12, loader-utils@^0.2.15, loader-utils@^0.2.16: json5 "^0.5.0" object-assign "^4.0.1" -loader-utils@^1.0.0, loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0: +loader-utils@^1.0.0, loader-utils@^1.0.2, loader-utils@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" integrity sha1-yYrvSIvM7aL/teLeZG1qdUQp9c0= @@ -6157,7 +6151,7 @@ loader-utils@^1.0.0, loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1 emojis-list "^2.0.0" json5 "^0.5.0" -loader-utils@^1.0.4: +loader-utils@^1.0.1, loader-utils@^1.0.4: version "1.2.3" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7" dependencies: @@ -6371,11 +6365,6 @@ lodash.startswith@^4.2.1: resolved "https://registry.yarnpkg.com/lodash.startswith/-/lodash.startswith-4.2.1.tgz#c598c4adce188a27e53145731cdc6c0e7177600c" integrity sha1-xZjErc4YiiflMUVzHNxsDnF3YAw= -lodash.tail@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664" - integrity sha1-0jM6NtnncXyK0vfKyv7HwytERmQ= - lodash.template@^3.0.0: version "3.6.2" resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-3.6.2.tgz#f8cdecc6169a255be9098ae8b0c53d378931d14f" @@ -6849,14 +6838,6 @@ mixin-deep@^1.2.0: for-in "^1.0.2" is-extendable "^1.0.1" -mixin-object@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e" - integrity sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4= - dependencies: - for-in "^0.1.3" - is-extendable "^0.1.1" - mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: version "0.5.1" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" @@ -8967,6 +8948,11 @@ querystringify@^2.0.0: resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.0.0.tgz#fa3ed6e68eb15159457c89b37bc6472833195755" integrity sha512-eTPo5t/4bgaMNZxyjWx6N2a6AuE0mq51KWvpc7nU/MAqixcI6v6KrGUKES0HaomdnolQBBXU/++X6/QQ9KL4tw== +querystringify@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.1.1.tgz#60e5a5fd64a7f8bfa4d2ab2ed6fdf4c85bad154e" + integrity sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA== + random-bytes@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" @@ -9654,17 +9640,16 @@ sass-graph@^2.2.4: scss-tokenizer "^0.2.3" yargs "^7.0.0" -sass-loader@7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-7.1.0.tgz#16fd5138cb8b424bf8a759528a1972d72aad069d" - integrity sha512-+G+BKGglmZM2GUSfT9TLuEp6tzehHPjAMoRRItOojWIqIGPloVCMhNIQuG639eJ+y033PaGTSjLaTHts8Kw79w== +sass-loader@^7.1.0: + version "7.3.1" + resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-7.3.1.tgz#a5bf68a04bcea1c13ff842d747150f7ab7d0d23f" + integrity sha512-tuU7+zm0pTCynKYHpdqaPpe+MMTQ76I9TPZ7i4/5dZsigE350shQWe5EZNl5dBidM49TPET75tNqRbcsUZWeNA== dependencies: - clone-deep "^2.0.1" + clone-deep "^4.0.1" loader-utils "^1.0.1" - lodash.tail "^4.1.1" neo-async "^2.5.0" - pify "^3.0.0" - semver "^5.5.0" + pify "^4.0.1" + semver "^6.3.0" sass-resources-loader@^2.0.0: version "2.0.0" @@ -9769,7 +9754,7 @@ semver-intersect@^1.1.2: dependencies: semver "^5.0.0" -"semver@2 >=2.2.1 || 3.x || 4 || 5", "semver@2 || 3 || 4 || 5", semver@^5.0.0, semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.5.0: +"semver@2 >=2.2.1 || 3.x || 4 || 5", "semver@2 || 3 || 4 || 5", semver@^5.0.0, semver@^5.0.3, semver@^5.1.0, semver@^5.3.0: version "5.5.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.1.tgz#7dfdd8814bdb7cabc7be0fb1d734cfb66c940477" integrity sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw== @@ -9779,7 +9764,12 @@ semver@^5.0.1: resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004" integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg== -semver@^6.1.1: +semver@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + +semver@^6.1.1, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== @@ -9910,14 +9900,12 @@ sha.js@^2.4.0, sha.js@^2.4.8: inherits "^2.0.1" safe-buffer "^5.0.1" -shallow-clone@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-1.0.0.tgz#4480cd06e882ef68b2ad88a3ea54832e2c48b571" - integrity sha512-oeXreoKR/SyNJtRJMAKPDSvd28OqEwG4eR/xc856cRGBII7gX9lvAqDxusPm0846z/w/hWYjI1NpKwJ00NHzRA== +shallow-clone@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" + integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA== dependencies: - is-extendable "^0.1.1" - kind-of "^5.0.0" - mixin-object "^2.0.1" + kind-of "^6.0.2" shebang-command@^1.2.0: version "1.2.0" @@ -10833,6 +10821,11 @@ tsickle@^0.32.1: source-map "^0.6.0" source-map-support "^0.5.0" +tslib@^1.7.1: + version "1.10.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" + integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== + tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" @@ -11162,6 +11155,14 @@ url-parse@^1.4.3: querystringify "^2.0.0" requires-port "^1.0.0" +url-parse@^1.4.7: + version "1.4.7" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" + integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + url@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"