mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
Merge branch 'main' into patch-4
This commit is contained in:
74
.github/workflows/build.yml
vendored
74
.github/workflows/build.yml
vendored
@@ -190,12 +190,84 @@ jobs:
|
|||||||
# Get homepage and verify that the <meta name="title"> tag includes "DSpace".
|
# Get homepage and verify that the <meta name="title"> tag includes "DSpace".
|
||||||
# If it does, then SSR is working, as this tag is created by our MetadataService.
|
# If it does, then SSR is working, as this tag is created by our MetadataService.
|
||||||
# This step also prints entire HTML of homepage for easier debugging if grep fails.
|
# This step also prints entire HTML of homepage for easier debugging if grep fails.
|
||||||
- name: Verify SSR (server-side rendering)
|
- name: Verify SSR (server-side rendering) on Homepage
|
||||||
run: |
|
run: |
|
||||||
result=$(wget -O- -q http://127.0.0.1:4000/home)
|
result=$(wget -O- -q http://127.0.0.1:4000/home)
|
||||||
echo "$result"
|
echo "$result"
|
||||||
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep DSpace
|
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep DSpace
|
||||||
|
|
||||||
|
# Get a specific community in our test data and verify that the "<h1>" tag includes "Publications" (the community name).
|
||||||
|
# If it does, then SSR is working.
|
||||||
|
- name: Verify SSR on a Community page
|
||||||
|
run: |
|
||||||
|
result=$(wget -O- -q http://127.0.0.1:4000/communities/0958c910-2037-42a9-81c7-dca80e3892b4)
|
||||||
|
echo "$result"
|
||||||
|
echo "$result" | grep -oE "<h1 [^>]*>[^><]*</h1>" | grep Publications
|
||||||
|
|
||||||
|
# Get a specific collection in our test data and verify that the "<h1>" tag includes "Articles" (the collection name).
|
||||||
|
# If it does, then SSR is working.
|
||||||
|
- name: Verify SSR on a Collection page
|
||||||
|
run: |
|
||||||
|
result=$(wget -O- -q http://127.0.0.1:4000/collections/282164f5-d325-4740-8dd1-fa4d6d3e7200)
|
||||||
|
echo "$result"
|
||||||
|
echo "$result" | grep -oE "<h1 [^>]*>[^><]*</h1>" | grep Articles
|
||||||
|
|
||||||
|
# Get a specific publication in our test data and verify that the <meta name="title"> tag includes
|
||||||
|
# the title of this publication. If it does, then SSR is working.
|
||||||
|
- name: Verify SSR on a Publication page
|
||||||
|
run: |
|
||||||
|
result=$(wget -O- -q http://127.0.0.1:4000/entities/publication/6160810f-1e53-40db-81ef-f6621a727398)
|
||||||
|
echo "$result"
|
||||||
|
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep "An Economic Model of Mortality Salience"
|
||||||
|
|
||||||
|
# Get a specific person in our test data and verify that the <meta name="title"> tag includes
|
||||||
|
# the name of the person. If it does, then SSR is working.
|
||||||
|
- name: Verify SSR on a Person page
|
||||||
|
run: |
|
||||||
|
result=$(wget -O- -q http://127.0.0.1:4000/entities/person/b1b2c768-bda1-448a-a073-fc541e8b24d9)
|
||||||
|
echo "$result"
|
||||||
|
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep "Simmons, Cameron"
|
||||||
|
|
||||||
|
# Get a specific project in our test data and verify that the <meta name="title"> tag includes
|
||||||
|
# the name of the project. If it does, then SSR is working.
|
||||||
|
- name: Verify SSR on a Project page
|
||||||
|
run: |
|
||||||
|
result=$(wget -O- -q http://127.0.0.1:4000/entities/project/46ccb608-a74c-4bf6-bc7a-e29cc7defea9)
|
||||||
|
echo "$result"
|
||||||
|
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep "University Research Fellowship"
|
||||||
|
|
||||||
|
# Get a specific orgunit in our test data and verify that the <meta name="title"> tag includes
|
||||||
|
# the name of the orgunit. If it does, then SSR is working.
|
||||||
|
- name: Verify SSR on an OrgUnit page
|
||||||
|
run: |
|
||||||
|
result=$(wget -O- -q http://127.0.0.1:4000/entities/orgunit/9851674d-bd9a-467b-8d84-068deb568ccf)
|
||||||
|
echo "$result"
|
||||||
|
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep "Law and Development"
|
||||||
|
|
||||||
|
# Get a specific journal in our test data and verify that the <meta name="title"> tag includes
|
||||||
|
# the name of the journal. If it does, then SSR is working.
|
||||||
|
- name: Verify SSR on a Journal page
|
||||||
|
run: |
|
||||||
|
result=$(wget -O- -q http://127.0.0.1:4000/entities/journal/d4af6c3e-53d0-4757-81eb-566f3b45d63a)
|
||||||
|
echo "$result"
|
||||||
|
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep "Environmental & Architectural Phenomenology"
|
||||||
|
|
||||||
|
# Get a specific journal volume in our test data and verify that the <meta name="title"> tag includes
|
||||||
|
# the name of the volume. If it does, then SSR is working.
|
||||||
|
- name: Verify SSR on a Journal Volume page
|
||||||
|
run: |
|
||||||
|
result=$(wget -O- -q http://127.0.0.1:4000/entities/journalvolume/07c6249f-4bf7-494d-9ce3-6ffdb2aed538)
|
||||||
|
echo "$result"
|
||||||
|
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep "Environmental & Architectural Phenomenology Volume 28 (2017)"
|
||||||
|
|
||||||
|
# Get a specific journal issue in our test data and verify that the <meta name="title"> tag includes
|
||||||
|
# the name of the issue. If it does, then SSR is working.
|
||||||
|
- name: Verify SSR on a Journal Issue page
|
||||||
|
run: |
|
||||||
|
result=$(wget -O- -q http://127.0.0.1:4000/entities/journalissue/44c29473-5de2-48fa-b005-e5029aa1a50b)
|
||||||
|
echo "$result"
|
||||||
|
echo "$result" | grep -oE "<meta name=\"title\" [^>]*>" | grep "Environmental & Architectural Phenomenology Vol. 28, No. 1"
|
||||||
|
|
||||||
- name: Stop running app
|
- name: Stop running app
|
||||||
run: kill -9 $(lsof -t -i:4000)
|
run: kill -9 $(lsof -t -i:4000)
|
||||||
|
|
||||||
|
@@ -58,7 +58,10 @@
|
|||||||
"input": "src/themes/dspace/styles/theme.scss",
|
"input": "src/themes/dspace/styles/theme.scss",
|
||||||
"inject": false,
|
"inject": false,
|
||||||
"bundleName": "dspace-theme"
|
"bundleName": "dspace-theme"
|
||||||
}
|
},
|
||||||
|
"node_modules/leaflet/dist/leaflet.css",
|
||||||
|
"node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css",
|
||||||
|
"node_modules/leaflet.markercluster/dist/MarkerCluster.css"
|
||||||
],
|
],
|
||||||
"scripts": [],
|
"scripts": [],
|
||||||
"baseHref": "/"
|
"baseHref": "/"
|
||||||
|
@@ -350,15 +350,25 @@ item:
|
|||||||
# Rounded to the nearest size in the list of selectable sizes on the
|
# Rounded to the nearest size in the list of selectable sizes on the
|
||||||
# settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'.
|
# settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'.
|
||||||
pageSize: 5
|
pageSize: 5
|
||||||
|
# Show the bitstream access status label on the item page
|
||||||
|
showAccessStatuses: false
|
||||||
|
|
||||||
# Community Page Config
|
# Community Page Config
|
||||||
community:
|
community:
|
||||||
|
# Default tab to be shown when browsing a Community. Valid values are: comcols, search, or browse_<field>
|
||||||
|
# <field> must be any of the configured "browse by" fields, e.g., dateissued, author, title, or subject
|
||||||
|
# When the default tab is not the 'search' tab, the search tab is moved to the last position
|
||||||
|
defaultBrowseTab: search
|
||||||
# Search tab config
|
# Search tab config
|
||||||
searchSection:
|
searchSection:
|
||||||
showSidebar: true
|
showSidebar: true
|
||||||
|
|
||||||
# Collection Page Config
|
# Collection Page Config
|
||||||
collection:
|
collection:
|
||||||
|
# Default tab to be shown when browsing a Collection. Valid values are: search, or browse_<field>
|
||||||
|
# <field> must be any of the configured "browse by" fields, e.g., dateissued, author, title, or subject
|
||||||
|
# When the default tab is not the 'search' tab, the search tab is moved to the last position
|
||||||
|
defaultBrowseTab: search
|
||||||
# Search tab config
|
# Search tab config
|
||||||
searchSection:
|
searchSection:
|
||||||
showSidebar: true
|
showSidebar: true
|
||||||
@@ -536,7 +546,6 @@ notifyMetrics:
|
|||||||
config: 'NOTIFY.outgoing.delivered'
|
config: 'NOTIFY.outgoing.delivered'
|
||||||
description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description'
|
description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description'
|
||||||
|
|
||||||
|
|
||||||
# Live Region configuration
|
# Live Region configuration
|
||||||
# Live Region as defined by w3c, https://www.w3.org/TR/wai-aria-1.1/#terms:
|
# Live Region as defined by w3c, https://www.w3.org/TR/wai-aria-1.1/#terms:
|
||||||
# Live regions are perceivable regions of a web page that are typically updated as a
|
# Live regions are perceivable regions of a web page that are typically updated as a
|
||||||
@@ -550,3 +559,37 @@ liveRegion:
|
|||||||
messageTimeOutDurationMs: 30000
|
messageTimeOutDurationMs: 30000
|
||||||
# The visibility of the live region. Setting this to true is only useful for debugging purposes.
|
# The visibility of the live region. Setting this to true is only useful for debugging purposes.
|
||||||
isVisible: false
|
isVisible: false
|
||||||
|
|
||||||
|
# Geospatial Map display options
|
||||||
|
geospatialMapViewer:
|
||||||
|
# Which fields to use for parsing as geospatial points in search maps
|
||||||
|
# (note, the item page field component allows any field(s) to be used
|
||||||
|
# and is set as an input when declaring the component)
|
||||||
|
spatialMetadataFields:
|
||||||
|
- 'dcterms.spatial'
|
||||||
|
# Which discovery configuration to use for 'geospatial search', used
|
||||||
|
# in the browse map
|
||||||
|
spatialFacetDiscoveryConfiguration: 'geospatial'
|
||||||
|
# Which filter / facet name to use for faceted geospatial search
|
||||||
|
# used in the browse map
|
||||||
|
spatialPointFilterName: 'point'
|
||||||
|
# Whether item page geospatial metadata should be displayed
|
||||||
|
# (assumes they are wrapped in a test for this config in the template as
|
||||||
|
# per the default templates supplied with DSpace for untyped-item and publication)
|
||||||
|
enableItemPageFields: false
|
||||||
|
# Whether the browse map should be enabled and included in the browse menu
|
||||||
|
enableBrowseMap: false
|
||||||
|
# Whether a 'map view' mode should be included alongside list and grid views
|
||||||
|
# in search result pages
|
||||||
|
enableSearchViewMode: false
|
||||||
|
# The tile provider(s) to use for the map tiles drawn in the leaflet maps.
|
||||||
|
# (see https://leaflet-extras.github.io/leaflet-providers/preview/) for a full list
|
||||||
|
tileProviders:
|
||||||
|
- 'OpenStreetMap.Mapnik'
|
||||||
|
# Starting centre point for the map, as lat and lng coordinates. This is useful
|
||||||
|
# to set the centre of the map when the map is first loaded and if there are no
|
||||||
|
# points, shapes or markers to display.
|
||||||
|
# Defaults to the centre of Istanbul
|
||||||
|
defaultCentrePoint:
|
||||||
|
lat: 41.015137
|
||||||
|
lng: 28.979530
|
||||||
|
@@ -7,7 +7,7 @@ describe('Item Statistics Page', () => {
|
|||||||
it('should load if you click on "Statistics" from an Item/Entity page', () => {
|
it('should load if you click on "Statistics" from an Item/Entity page', () => {
|
||||||
cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')));
|
cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')));
|
||||||
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
|
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
|
||||||
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
|
cy.location('pathname').should('eq', '/statistics/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => {
|
it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => {
|
||||||
|
1916
package-lock.json
generated
1916
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
38
package.json
38
package.json
@@ -102,8 +102,8 @@
|
|||||||
"@angular/platform-browser-dynamic": "^18.2.12",
|
"@angular/platform-browser-dynamic": "^18.2.12",
|
||||||
"@angular/platform-server": "^18.2.12",
|
"@angular/platform-server": "^18.2.12",
|
||||||
"@angular/router": "^18.2.12",
|
"@angular/router": "^18.2.12",
|
||||||
"@angular/ssr": "^18.2.12",
|
"@angular/ssr": "^18.2.18",
|
||||||
"@babel/runtime": "7.26.0",
|
"@babel/runtime": "7.27.0",
|
||||||
"@kolkov/ngx-gallery": "^2.0.1",
|
"@kolkov/ngx-gallery": "^2.0.1",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^12.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^12.0.0",
|
||||||
"@ng-dynamic-forms/core": "^16.0.0",
|
"@ng-dynamic-forms/core": "^16.0.0",
|
||||||
@@ -114,15 +114,17 @@
|
|||||||
"@ngrx/store": "^18.1.1",
|
"@ngrx/store": "^18.1.1",
|
||||||
"@ngx-translate/core": "^16.0.3",
|
"@ngx-translate/core": "^16.0.3",
|
||||||
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
|
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
|
||||||
|
"@terraformer/wkt": "^2.2.1",
|
||||||
|
"altcha": "^0.9.0",
|
||||||
"angulartics2": "^12.2.0",
|
"angulartics2": "^12.2.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.8.4",
|
||||||
"bootstrap": "^5.3",
|
"bootstrap": "^5.3",
|
||||||
"cerialize": "0.1.18",
|
"cerialize": "0.1.18",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
"colors": "^1.4.0",
|
"colors": "^1.4.0",
|
||||||
"compression": "^1.7.5",
|
"compression": "^1.7.5",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"core-js": "^3.40.0",
|
"core-js": "^3.41.0",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
"date-fns-tz": "^1.3.7",
|
"date-fns-tz": "^1.3.7",
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
@@ -130,16 +132,19 @@
|
|||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-rate-limit": "^5.1.3",
|
"express-rate-limit": "^5.1.3",
|
||||||
"fast-json-patch": "^3.1.1",
|
"fast-json-patch": "^3.1.1",
|
||||||
"filesize": "^6.1.0",
|
"filesize": "^10.1.6",
|
||||||
"http-proxy-middleware": "^2.0.7",
|
"http-proxy-middleware": "^2.0.9",
|
||||||
"http-terminator": "^3.2.0",
|
"http-terminator": "^3.2.0",
|
||||||
"isbot": "^5.1.22",
|
"isbot": "^5.1.26",
|
||||||
"js-cookie": "2.2.1",
|
"js-cookie": "2.2.1",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"jsonschema": "1.5.0",
|
"jsonschema": "1.5.0",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"lodash": "^4.17.21",
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet-providers": "^2.0.0",
|
||||||
|
"leaflet.markercluster": "^1.5.3",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
"lru-cache": "^7.14.1",
|
"lru-cache": "^7.14.1",
|
||||||
"markdown-it": "^13.0.1",
|
"markdown-it": "^13.0.1",
|
||||||
"mirador": "^3.4.3",
|
"mirador": "^3.4.3",
|
||||||
@@ -149,6 +154,7 @@
|
|||||||
"ng2-file-upload": "7.0.1",
|
"ng2-file-upload": "7.0.1",
|
||||||
"ng2-nouislider": "^2.0.0",
|
"ng2-nouislider": "^2.0.0",
|
||||||
"ngx-infinite-scroll": "^18.0.0",
|
"ngx-infinite-scroll": "^18.0.0",
|
||||||
|
"ngx-matomo-client": "^6.4.1",
|
||||||
"ngx-pagination": "6.0.3",
|
"ngx-pagination": "6.0.3",
|
||||||
"ngx-skeleton-loader": "^9.0.0",
|
"ngx-skeleton-loader": "^9.0.0",
|
||||||
"ngx-ui-switch": "^15.0.0",
|
"ngx-ui-switch": "^15.0.0",
|
||||||
@@ -162,7 +168,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "~18.0.0",
|
"@angular-builders/custom-webpack": "~18.0.0",
|
||||||
"@angular-devkit/build-angular": "^18.2.12",
|
"@angular-devkit/build-angular": "^18.2.18",
|
||||||
"@angular-eslint/builder": "^18.4.1",
|
"@angular-eslint/builder": "^18.4.1",
|
||||||
"@angular-eslint/bundled-angular-compiler": "^18.4.1",
|
"@angular-eslint/bundled-angular-compiler": "^18.4.1",
|
||||||
"@angular-eslint/eslint-plugin": "^18.4.1",
|
"@angular-eslint/eslint-plugin": "^18.4.1",
|
||||||
@@ -170,20 +176,20 @@
|
|||||||
"@angular-eslint/schematics": "^18.4.1",
|
"@angular-eslint/schematics": "^18.4.1",
|
||||||
"@angular-eslint/template-parser": "^18.4.1",
|
"@angular-eslint/template-parser": "^18.4.1",
|
||||||
"@angular-eslint/utils": "^18.4.1",
|
"@angular-eslint/utils": "^18.4.1",
|
||||||
"@angular/cli": "^18.2.12",
|
"@angular/cli": "^18.2.18",
|
||||||
"@angular/compiler-cli": "^18.2.12",
|
"@angular/compiler-cli": "^18.2.12",
|
||||||
"@angular/language-service": "^18.2.12",
|
"@angular/language-service": "^18.2.12",
|
||||||
"@cypress/schematic": "^1.5.0",
|
"@cypress/schematic": "^1.5.0",
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
"@ngrx/store-devtools": "^18.1.1",
|
"@ngrx/store-devtools": "^18.1.1",
|
||||||
"@ngtools/webpack": "^18.2.12",
|
"@ngtools/webpack": "^18.2.18",
|
||||||
"@types/deep-freeze": "0.1.5",
|
"@types/deep-freeze": "0.1.5",
|
||||||
"@types/ejs": "^3.1.2",
|
"@types/ejs": "^3.1.2",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/grecaptcha": "^3.0.9",
|
"@types/grecaptcha": "^3.0.9",
|
||||||
"@types/jasmine": "~3.6.0",
|
"@types/jasmine": "~3.6.0",
|
||||||
"@types/js-cookie": "2.2.6",
|
"@types/js-cookie": "2.2.6",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^14.14.9",
|
"@types/node": "^14.14.9",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
"@typescript-eslint/parser": "^7.18.0",
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
@@ -203,7 +209,7 @@
|
|||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-import-newlines": "^1.3.1",
|
"eslint-plugin-import-newlines": "^1.3.1",
|
||||||
"eslint-plugin-jsdoc": "^45.0.0",
|
"eslint-plugin-jsdoc": "^45.0.0",
|
||||||
"eslint-plugin-jsonc": "^2.19.1",
|
"eslint-plugin-jsonc": "^2.20.0",
|
||||||
"eslint-plugin-lodash": "^7.4.0",
|
"eslint-plugin-lodash": "^7.4.0",
|
||||||
"eslint-plugin-rxjs": "^5.0.3",
|
"eslint-plugin-rxjs": "^5.0.3",
|
||||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||||
@@ -218,7 +224,7 @@
|
|||||||
"karma-jasmine": "~4.0.0",
|
"karma-jasmine": "~4.0.0",
|
||||||
"karma-jasmine-html-reporter": "^1.5.0",
|
"karma-jasmine-html-reporter": "^1.5.0",
|
||||||
"karma-mocha-reporter": "2.2.5",
|
"karma-mocha-reporter": "2.2.5",
|
||||||
"ng-mocks": "^14.13.2",
|
"ng-mocks": "^14.13.4",
|
||||||
"ngx-mask": "14.2.4",
|
"ngx-mask": "14.2.4",
|
||||||
"nodemon": "^2.0.22",
|
"nodemon": "^2.0.22",
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
@@ -226,12 +232,12 @@
|
|||||||
"postcss-loader": "^4.0.3",
|
"postcss-loader": "^4.0.3",
|
||||||
"postcss-preset-env": "^7.4.2",
|
"postcss-preset-env": "^7.4.2",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"sass": "~1.84.0",
|
"sass": "~1.86.3",
|
||||||
"sass-loader": "^12.6.0",
|
"sass-loader": "^12.6.0",
|
||||||
"sass-resources-loader": "^2.2.5",
|
"sass-resources-loader": "^2.2.5",
|
||||||
"ts-node": "^8.10.2",
|
"ts-node": "^8.10.2",
|
||||||
"typescript": "~5.4.5",
|
"typescript": "~5.4.5",
|
||||||
"webpack": "5.97.1",
|
"webpack": "5.99.5",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "^5.1.4",
|
||||||
"webpack-dev-server": "^4.15.1"
|
"webpack-dev-server": "^4.15.1"
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,7 @@
|
|||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
NO_ERRORS_SCHEMA,
|
||||||
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
ComponentFixture,
|
ComponentFixture,
|
||||||
TestBed,
|
TestBed,
|
||||||
@@ -62,10 +65,17 @@ describe('BulkAccessComponent', () => {
|
|||||||
'file': { },
|
'file': { },
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSettings: any = jasmine.createSpyObj('AccessControlFormContainerComponent', {
|
@Component({
|
||||||
getValue: jasmine.createSpy('getValue'),
|
selector: 'ds-bulk-access-settings',
|
||||||
reset: jasmine.createSpy('reset'),
|
template: '',
|
||||||
});
|
exportAs: 'dsBulkSettings',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
class MockBulkAccessSettingsComponent {
|
||||||
|
isFormValid = jasmine.createSpy('isFormValid').and.returnValue(false);
|
||||||
|
getValue = jasmine.createSpy('getValue');
|
||||||
|
reset = jasmine.createSpy('reset');
|
||||||
|
}
|
||||||
const selection: any[] = [{ indexableObject: { uuid: '1234' } }, { indexableObject: { uuid: '5678' } }];
|
const selection: any[] = [{ indexableObject: { uuid: '1234' } }, { indexableObject: { uuid: '5678' } }];
|
||||||
const selectableListState: SelectableListState = { id: 'test', selection };
|
const selectableListState: SelectableListState = { id: 'test', selection };
|
||||||
const expectedIdList = ['1234', '5678'];
|
const expectedIdList = ['1234', '5678'];
|
||||||
@@ -93,6 +103,9 @@ describe('BulkAccessComponent', () => {
|
|||||||
BulkAccessSettingsComponent,
|
BulkAccessSettingsComponent,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
add: {
|
||||||
|
imports: [MockBulkAccessSettingsComponent],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
});
|
});
|
||||||
@@ -109,13 +122,12 @@ describe('BulkAccessComponent', () => {
|
|||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when there are no elements selected', () => {
|
describe('when there are no elements selected and step two form is invalid', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
||||||
(component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListStateEmpty));
|
(component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListStateEmpty));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
component.settings = mockSettings;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
@@ -138,7 +150,6 @@ describe('BulkAccessComponent', () => {
|
|||||||
|
|
||||||
(component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListState));
|
(component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListState));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
component.settings = mockSettings;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
@@ -149,15 +160,29 @@ describe('BulkAccessComponent', () => {
|
|||||||
expect(component.objectsSelected$.value).toEqual(expectedIdList);
|
expect(component.objectsSelected$.value).toEqual(expectedIdList);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should enable the execute button when there are objects selected', () => {
|
it('should not enable the execute button when there are objects selected and step two form is invalid', () => {
|
||||||
component.objectsSelected$.next(['1234']);
|
component.objectsSelected$.next(['1234']);
|
||||||
expect(component.canExport()).toBe(true);
|
expect(component.canExport()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call the settings reset method when reset is called', () => {
|
it('should call the settings reset method when reset is called', () => {
|
||||||
component.reset();
|
component.reset();
|
||||||
expect(component.settings.reset).toHaveBeenCalled();
|
expect(component.settings.reset).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
describe('when there are elements selected and the step two form is valid', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
|
||||||
|
(component as any).selectableListService.getSelectableList.and.returnValue(of(selectableListState));
|
||||||
|
fixture.detectChanges();
|
||||||
|
(component as any).settings.isFormValid.and.returnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable the execute button when there are objects selected and step two form is valid', () => {
|
||||||
|
component.objectsSelected$.next(['1234']);
|
||||||
|
expect(component.canExport()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('should call the bulkAccessControlService executeScript method when submit is called', () => {
|
it('should call the bulkAccessControlService executeScript method when submit is called', () => {
|
||||||
(component.settings as any).getValue.and.returnValue(mockFormState);
|
(component.settings as any).getValue.and.returnValue(mockFormState);
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
OnInit,
|
OnInit,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
@@ -31,6 +32,7 @@ import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.com
|
|||||||
BtnDisabledDirective,
|
BtnDisabledDirective,
|
||||||
],
|
],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class BulkAccessComponent implements OnInit {
|
export class BulkAccessComponent implements OnInit {
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@ export class BulkAccessComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canExport(): boolean {
|
canExport(): boolean {
|
||||||
return this.objectsSelected$.value?.length > 0;
|
return this.objectsSelected$.value?.length > 0 && this.settings?.isFormValid();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -42,4 +42,8 @@ export class BulkAccessSettingsComponent {
|
|||||||
this.controlForm.reset();
|
this.controlForm.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isFormValid() {
|
||||||
|
return this.controlForm.isValid();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -1 +1 @@
|
|||||||
<ds-publication-claim [source]="'openaire'"></ds-publication-claim>
|
<ds-suggestion-sources></ds-suggestion-sources>
|
||||||
|
@@ -6,8 +6,9 @@ import {
|
|||||||
waitForAsync,
|
waitForAsync,
|
||||||
} from '@angular/core/testing';
|
} from '@angular/core/testing';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { MockComponent } from 'ng-mocks';
|
||||||
|
|
||||||
import { PublicationClaimComponent } from '../../../notifications/suggestion-targets/publication-claim/publication-claim.component';
|
import { SuggestionSourcesComponent } from '../../../notifications/suggestions/sources/suggestion-sources.component';
|
||||||
import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page.component';
|
import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page.component';
|
||||||
|
|
||||||
describe('AdminNotificationsPublicationClaimPageComponent', () => {
|
describe('AdminNotificationsPublicationClaimPageComponent', () => {
|
||||||
@@ -20,17 +21,10 @@ describe('AdminNotificationsPublicationClaimPageComponent', () => {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
TranslateModule.forRoot(),
|
TranslateModule.forRoot(),
|
||||||
AdminNotificationsPublicationClaimPageComponent,
|
AdminNotificationsPublicationClaimPageComponent,
|
||||||
],
|
MockComponent(SuggestionSourcesComponent),
|
||||||
providers: [
|
|
||||||
AdminNotificationsPublicationClaimPageComponent,
|
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA],
|
schemas: [NO_ERRORS_SCHEMA],
|
||||||
}).overrideComponent(AdminNotificationsPublicationClaimPageComponent, {
|
}).compileComponents();
|
||||||
remove: {
|
|
||||||
imports: [PublicationClaimComponent],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@@ -1,14 +1,12 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
import { PublicationClaimComponent } from '../../../notifications/suggestion-targets/publication-claim/publication-claim.component';
|
import { SuggestionSourcesComponent } from '../../../notifications/suggestions/sources/suggestion-sources.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-admin-notifications-publication-claim-page',
|
selector: 'ds-admin-notifications-publication-claim-page',
|
||||||
templateUrl: './admin-notifications-publication-claim-page.component.html',
|
templateUrl: './admin-notifications-publication-claim-page.component.html',
|
||||||
styleUrls: ['./admin-notifications-publication-claim-page.component.scss'],
|
styleUrls: ['./admin-notifications-publication-claim-page.component.scss'],
|
||||||
imports: [
|
imports: [ SuggestionSourcesComponent ],
|
||||||
PublicationClaimComponent,
|
|
||||||
],
|
|
||||||
standalone: true,
|
standalone: true,
|
||||||
})
|
})
|
||||||
export class AdminNotificationsPublicationClaimPageComponent {
|
export class AdminNotificationsPublicationClaimPageComponent {
|
||||||
|
@@ -2,7 +2,8 @@ import { Route } from '@angular/router';
|
|||||||
|
|
||||||
import { authenticatedGuard } from '../../core/auth/authenticated.guard';
|
import { authenticatedGuard } from '../../core/auth/authenticated.guard';
|
||||||
import { i18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
|
import { i18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
|
||||||
import { qualityAssuranceBreadcrumbResolver } from '../../core/breadcrumbs/quality-assurance-breadcrumb.resolver';
|
import { sourcesBreadcrumbResolver } from '../../core/breadcrumbs/sources-breadcrumb.resolver';
|
||||||
|
import { PublicationClaimComponent } from '../../notifications/suggestions/targets/publication-claim/publication-claim.component';
|
||||||
import { AdminNotificationsPublicationClaimPageResolver } from '../../quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service';
|
import { AdminNotificationsPublicationClaimPageResolver } from '../../quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service';
|
||||||
import { QualityAssuranceEventsPageComponent } from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component';
|
import { QualityAssuranceEventsPageComponent } from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component';
|
||||||
import { qualityAssuranceEventsPageResolver } from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.resolver';
|
import { qualityAssuranceEventsPageResolver } from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.resolver';
|
||||||
@@ -33,13 +34,28 @@ export const ROUTES: Route[] = [
|
|||||||
showBreadcrumbsFluid: false,
|
showBreadcrumbsFluid: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
canActivate: [ authenticatedGuard ],
|
||||||
|
path: `${PUBLICATION_CLAIMS_PATH}/:sourceId`,
|
||||||
|
pathMatch: 'full',
|
||||||
|
component: PublicationClaimComponent,
|
||||||
|
resolve: {
|
||||||
|
breadcrumb: sourcesBreadcrumbResolver,
|
||||||
|
openaireQualityAssuranceEventsParams: AdminNotificationsPublicationClaimPageResolver,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title: 'admin.notifications.publicationclaim.page.title',
|
||||||
|
breadcrumbKey: 'admin.notifications.publicationclaim',
|
||||||
|
showBreadcrumbsFluid: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
canActivate: [authenticatedGuard],
|
canActivate: [authenticatedGuard],
|
||||||
path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`,
|
path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`,
|
||||||
component: QualityAssuranceTopicsPageComponent,
|
component: QualityAssuranceTopicsPageComponent,
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
resolve: {
|
resolve: {
|
||||||
breadcrumb: qualityAssuranceBreadcrumbResolver,
|
breadcrumb: sourcesBreadcrumbResolver,
|
||||||
openaireQualityAssuranceTopicsParams: QualityAssuranceTopicsPageResolver,
|
openaireQualityAssuranceTopicsParams: QualityAssuranceTopicsPageResolver,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
@@ -85,7 +101,7 @@ export const ROUTES: Route[] = [
|
|||||||
component: QualityAssuranceEventsPageComponent,
|
component: QualityAssuranceEventsPageComponent,
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
resolve: {
|
resolve: {
|
||||||
breadcrumb: qualityAssuranceBreadcrumbResolver,
|
breadcrumb: sourcesBreadcrumbResolver,
|
||||||
openaireQualityAssuranceEventsParams: qualityAssuranceEventsPageResolver,
|
openaireQualityAssuranceEventsParams: qualityAssuranceEventsPageResolver,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
|
@@ -0,0 +1,8 @@
|
|||||||
|
@if (shouldShowButton$ | async) {
|
||||||
|
<button class="export-button btn btn-dark btn-sm"
|
||||||
|
[ngbTooltip]="tooltipMsg | translate"
|
||||||
|
(click)="export()"
|
||||||
|
[title]="tooltipMsg | translate" [attr.aria-label]="tooltipMsg | translate">
|
||||||
|
<i class="fas fa-file-export fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
}
|
@@ -0,0 +1,4 @@
|
|||||||
|
.export-button {
|
||||||
|
background: var(--ds-admin-sidebar-bg);
|
||||||
|
border-color: var(--ds-admin-sidebar-bg);
|
||||||
|
}
|
@@ -0,0 +1,194 @@
|
|||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
waitForAsync,
|
||||||
|
} from '@angular/core/testing';
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormGroup,
|
||||||
|
} from '@angular/forms';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { of as observableOf } from 'rxjs';
|
||||||
|
|
||||||
|
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
|
||||||
|
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
|
||||||
|
import { getProcessDetailRoute } from '../../../../process-page/process-page-routing.paths';
|
||||||
|
import { Process } from '../../../../process-page/processes/process.model';
|
||||||
|
import { Script } from '../../../../process-page/scripts/script.model';
|
||||||
|
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||||
|
import {
|
||||||
|
createFailedRemoteDataObject$,
|
||||||
|
createSuccessfulRemoteDataObject$,
|
||||||
|
} from '../../../../shared/remote-data.utils';
|
||||||
|
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
|
||||||
|
import { FiltersComponent } from '../../filters-section/filters-section.component';
|
||||||
|
import { OptionVO } from '../option-vo.model';
|
||||||
|
import { QueryPredicate } from '../query-predicate.model';
|
||||||
|
import { FilteredItemsExportCsvComponent } from './filtered-items-export-csv.component';
|
||||||
|
|
||||||
|
describe('FilteredItemsExportCsvComponent', () => {
|
||||||
|
let component: FilteredItemsExportCsvComponent;
|
||||||
|
let fixture: ComponentFixture<FilteredItemsExportCsvComponent>;
|
||||||
|
|
||||||
|
let scriptDataService: ScriptDataService;
|
||||||
|
let authorizationDataService: AuthorizationDataService;
|
||||||
|
let notificationsService;
|
||||||
|
let router;
|
||||||
|
|
||||||
|
const script = Object.assign(new Script(), { id: 'metadata-export-filtered-items-report', name: 'metadata-export-filtered-items-report' });
|
||||||
|
const process = Object.assign(new Process(), { processId: 5, scriptName: 'metadata-export-filtered-items-report' });
|
||||||
|
|
||||||
|
const params = new FormGroup({
|
||||||
|
collections: new FormControl([OptionVO.collection('1', 'coll1')]),
|
||||||
|
queryPredicates: new FormControl([QueryPredicate.of('name', 'equals', 'coll1')]),
|
||||||
|
filters: new FormControl([FiltersComponent.getFilter('is_item')]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyParams = new FormGroup({
|
||||||
|
collections: new FormControl([]),
|
||||||
|
queryPredicates: new FormControl([]),
|
||||||
|
filters: new FormControl([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
function initBeforeEachAsync() {
|
||||||
|
scriptDataService = jasmine.createSpyObj('scriptDataService', {
|
||||||
|
findById: createSuccessfulRemoteDataObject$(script),
|
||||||
|
invoke: createSuccessfulRemoteDataObject$(process),
|
||||||
|
});
|
||||||
|
authorizationDataService = jasmine.createSpyObj('authorizationService', {
|
||||||
|
isAuthorized: observableOf(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationsService = new NotificationsServiceStub();
|
||||||
|
|
||||||
|
router = jasmine.createSpyObj('authorizationService', ['navigateByUrl']);
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [TranslateModule.forRoot(), NgbModule, FilteredItemsExportCsvComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: ScriptDataService, useValue: scriptDataService },
|
||||||
|
{ provide: AuthorizationDataService, useValue: authorizationDataService },
|
||||||
|
{ provide: NotificationsService, useValue: notificationsService },
|
||||||
|
{ provide: Router, useValue: router },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initBeforeEach() {
|
||||||
|
fixture = TestBed.createComponent(FilteredItemsExportCsvComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
component.reportParams = params;
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('init', () => {
|
||||||
|
describe('comp', () => {
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
initBeforeEachAsync();
|
||||||
|
}));
|
||||||
|
beforeEach(() => {
|
||||||
|
initBeforeEach();
|
||||||
|
});
|
||||||
|
it('should init the comp', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('when the user is an admin and the metadata-export-filtered-items-report script is present ', () => {
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
initBeforeEachAsync();
|
||||||
|
}));
|
||||||
|
beforeEach(() => {
|
||||||
|
initBeforeEach();
|
||||||
|
});
|
||||||
|
it('should add the button', () => {
|
||||||
|
const debugElement = fixture.debugElement.query(By.css('button.export-button'));
|
||||||
|
expect(debugElement).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('when the user is not an admin', () => {
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
initBeforeEachAsync();
|
||||||
|
(authorizationDataService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false));
|
||||||
|
}));
|
||||||
|
beforeEach(() => {
|
||||||
|
initBeforeEach();
|
||||||
|
});
|
||||||
|
it('should not add the button', () => {
|
||||||
|
const debugElement = fixture.debugElement.query(By.css('button.export-button'));
|
||||||
|
expect(debugElement).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('when the metadata-export-filtered-items-report script is not present', () => {
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
initBeforeEachAsync();
|
||||||
|
(scriptDataService.findById as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Not found', 404));
|
||||||
|
}));
|
||||||
|
beforeEach(() => {
|
||||||
|
initBeforeEach();
|
||||||
|
});
|
||||||
|
it('should should not add the button', () => {
|
||||||
|
const debugElement = fixture.debugElement.query(By.css('button.export-button'));
|
||||||
|
expect(debugElement).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('export', () => {
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
initBeforeEachAsync();
|
||||||
|
}));
|
||||||
|
beforeEach(() => {
|
||||||
|
initBeforeEach();
|
||||||
|
});
|
||||||
|
it('should call the invoke script method with the correct parameters', () => {
|
||||||
|
// Parameterized export
|
||||||
|
component.export();
|
||||||
|
expect(scriptDataService.invoke).toHaveBeenCalledWith('metadata-export-filtered-items-report',
|
||||||
|
[
|
||||||
|
{ name: '-c', value: params.value.collections[0].id },
|
||||||
|
{ name: '-qp', value: QueryPredicate.toString(params.value.queryPredicates[0]) },
|
||||||
|
{ name: '-f', value: FiltersComponent.toQueryString(params.value.filters) },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
// Non-parameterized export
|
||||||
|
component.reportParams = emptyParams;
|
||||||
|
fixture.detectChanges();
|
||||||
|
component.export();
|
||||||
|
expect(scriptDataService.invoke).toHaveBeenCalledWith('metadata-export-filtered-items-report', [], []);
|
||||||
|
|
||||||
|
});
|
||||||
|
it('should show a success message when the script was invoked successfully and redirect to the corresponding process page', () => {
|
||||||
|
component.export();
|
||||||
|
|
||||||
|
expect(notificationsService.success).toHaveBeenCalled();
|
||||||
|
expect(router.navigateByUrl).toHaveBeenCalledWith(getProcessDetailRoute(process.processId));
|
||||||
|
});
|
||||||
|
it('should show an error message when the script was not invoked successfully and stay on the current page', () => {
|
||||||
|
(scriptDataService.invoke as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Error', 500));
|
||||||
|
|
||||||
|
component.export();
|
||||||
|
|
||||||
|
expect(notificationsService.error).toHaveBeenCalled();
|
||||||
|
expect(router.navigateByUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('clicking the button', () => {
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
initBeforeEachAsync();
|
||||||
|
}));
|
||||||
|
beforeEach(() => {
|
||||||
|
initBeforeEach();
|
||||||
|
});
|
||||||
|
it('should trigger the export function', () => {
|
||||||
|
spyOn(component, 'export');
|
||||||
|
|
||||||
|
const debugElement = fixture.debugElement.query(By.css('button.export-button'));
|
||||||
|
debugElement.triggerEventHandler('click', null);
|
||||||
|
|
||||||
|
expect(component.export).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,123 @@
|
|||||||
|
import { AsyncPipe } from '@angular/common';
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
Input,
|
||||||
|
OnInit,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormGroup } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import {
|
||||||
|
TranslateModule,
|
||||||
|
TranslateService,
|
||||||
|
} from '@ngx-translate/core';
|
||||||
|
import {
|
||||||
|
combineLatest as observableCombineLatest,
|
||||||
|
Observable,
|
||||||
|
} from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
|
||||||
|
import { FeatureID } from '../../../../core/data/feature-authorization/feature-id';
|
||||||
|
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
|
||||||
|
import { RemoteData } from '../../../../core/data/remote-data';
|
||||||
|
import { getFirstCompletedRemoteData } from '../../../../core/shared/operators';
|
||||||
|
import { getProcessDetailRoute } from '../../../../process-page/process-page-routing.paths';
|
||||||
|
import { Process } from '../../../../process-page/processes/process.model';
|
||||||
|
import { hasValue } from '../../../../shared/empty.util';
|
||||||
|
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||||
|
import { FiltersComponent } from '../../filters-section/filters-section.component';
|
||||||
|
import { OptionVO } from '../option-vo.model';
|
||||||
|
import { QueryPredicate } from '../query-predicate.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-filtered-items-export-csv',
|
||||||
|
styleUrls: ['./filtered-items-export-csv.component.scss'],
|
||||||
|
templateUrl: './filtered-items-export-csv.component.html',
|
||||||
|
standalone: true,
|
||||||
|
imports: [NgbTooltipModule, AsyncPipe, TranslateModule],
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Display a button to export the MetadataQuery (aka Filtered Items) Report results as csv
|
||||||
|
*/
|
||||||
|
export class FilteredItemsExportCsvComponent implements OnInit {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current configuration of the search
|
||||||
|
*/
|
||||||
|
@Input() reportParams: FormGroup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable used to determine whether the button should be shown
|
||||||
|
*/
|
||||||
|
shouldShowButton$: Observable<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message key used for the tooltip of the button
|
||||||
|
*/
|
||||||
|
tooltipMsg = 'metadata-export-filtered-items.tooltip';
|
||||||
|
|
||||||
|
constructor(private scriptDataService: ScriptDataService,
|
||||||
|
private authorizationDataService: AuthorizationDataService,
|
||||||
|
private notificationsService: NotificationsService,
|
||||||
|
private translateService: TranslateService,
|
||||||
|
private router: Router,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
static csvExportEnabled(scriptDataService: ScriptDataService, authorizationDataService: AuthorizationDataService): Observable<boolean> {
|
||||||
|
const scriptExists$ = scriptDataService.findById('metadata-export-filtered-items-report').pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
map((rd) => rd.isSuccess && hasValue(rd.payload)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isAuthorized$ = authorizationDataService.isAuthorized(FeatureID.AdministratorOf);
|
||||||
|
|
||||||
|
return observableCombineLatest([scriptExists$, isAuthorized$]).pipe(
|
||||||
|
map(([scriptExists, isAuthorized]: [boolean, boolean]) => scriptExists && isAuthorized),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.shouldShowButton$ = FilteredItemsExportCsvComponent.csvExportEnabled(this.scriptDataService, this.authorizationDataService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the export of the items based on the selected parameters
|
||||||
|
*/
|
||||||
|
export() {
|
||||||
|
const parameters = [];
|
||||||
|
const colls = this.reportParams.value.collections || [];
|
||||||
|
for (let i = 0; i < colls.length; i++) {
|
||||||
|
if (colls[i]) {
|
||||||
|
parameters.push({ name: '-c', value: OptionVO.toString(colls[i]) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const preds = this.reportParams.value.queryPredicates || [];
|
||||||
|
for (let i = 0; i < preds.length; i++) {
|
||||||
|
const field = preds[i].field;
|
||||||
|
const op = preds[i].operator;
|
||||||
|
if (field && op) {
|
||||||
|
parameters.push({ name: '-qp', value: QueryPredicate.toString(preds[i]) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = FiltersComponent.toQueryString(this.reportParams.value.filters) || [];
|
||||||
|
if (filters.length > 0) {
|
||||||
|
parameters.push({ name: '-f', value: filters });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scriptDataService.invoke('metadata-export-filtered-items-report', parameters, []).pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
).subscribe((rd: RemoteData<Process>) => {
|
||||||
|
if (rd.hasSucceeded) {
|
||||||
|
this.notificationsService.success(this.translateService.get('metadata-export-filtered-items.submit.success'));
|
||||||
|
this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId));
|
||||||
|
} else {
|
||||||
|
this.notificationsService.error(this.translateService.get('metadata-export-filtered-items.submit.error'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -11,11 +11,16 @@
|
|||||||
{{'admin.reports.items.section.collectionSelector' | translate}}
|
{{'admin.reports.items.section.collectionSelector' | translate}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template ngbPanelContent>
|
<ng-template ngbPanelContent>
|
||||||
|
@if (loadingCollections$ | async) {
|
||||||
|
<ds-loading></ds-loading>
|
||||||
|
}
|
||||||
|
@if ((loadingCollections$ | async) !== true) {
|
||||||
<select id="collSel" name="collSel" class="form-control" multiple="multiple" size="10" formControlName="collections">
|
<select id="collSel" name="collSel" class="form-control" multiple="multiple" size="10" formControlName="collections">
|
||||||
@for (item of collections; track item) {
|
@for (item of collections; track item) {
|
||||||
<option [value]="item.id" [disabled]="item.disabled">{{item.name$ | async}}</option>
|
<option [value]="item.id" [disabled]="item.disabled">{{item.name$ | async}}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
|
}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="col-3"></span>
|
<span class="col-3"></span>
|
||||||
<button class="btn btn-primary mt-1 col-6" (click)="submit()">{{'admin.reports.items.run' | translate}}</button>
|
<button class="btn btn-primary mt-1 col-6" (click)="submit()">{{'admin.reports.items.run' | translate}}</button>
|
||||||
@@ -132,6 +137,10 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
@if (csvExportEnabled$ | async) {
|
||||||
|
<span class="col-3"></span>
|
||||||
|
<div class="warning">{{ 'metadata-export-filtered-items.columns.warning' | translate }}</div>
|
||||||
|
}
|
||||||
<span class="col-3"></span>
|
<span class="col-3"></span>
|
||||||
<button class="btn btn-primary mt-1 col-6" (click)="submit()">{{'admin.reports.items.run' | translate}}</button>
|
<button class="btn btn-primary mt-1 col-6" (click)="submit()">{{'admin.reports.items.run' | translate}}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,9 +195,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<button id="prev" class="btn btn-light" (click)="prevPage()" [dsBtnDisabled]="!canNavigatePrevious()">{{'admin.reports.commons.previous-page' | translate}}</button>
|
<button id="prev" class="btn btn-light" (click)="prevPage()" [dsBtnDisabled]="!canNavigatePrevious()">{{'admin.reports.commons.previous-page' | translate}}</button>
|
||||||
<button id="next" class="btn btn-light" (click)="nextPage()" [dsBtnDisabled]="!canNavigateNext()">{{'admin.reports.commons.next-page' | translate}}</button>
|
<button id="next" class="btn btn-light" (click)="nextPage()" [dsBtnDisabled]="!canNavigateNext()">{{'admin.reports.commons.next-page' | translate}}</button>
|
||||||
<!--
|
<div style="float: right; margin-right: 60px;">
|
||||||
<button id="export">{{'admin.reports.commons.export' | translate}}</button>
|
<ds-filtered-items-export-csv [reportParams]="queryForm"></ds-filtered-items-export-csv>
|
||||||
-->
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table id="itemtable" class="sortable"></table>
|
<table id="itemtable" class="sortable"></table>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@@ -1,3 +1,10 @@
|
|||||||
.num {
|
.num {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
color: red;
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
@@ -20,13 +20,16 @@ import {
|
|||||||
TranslateService,
|
TranslateService,
|
||||||
} from '@ngx-translate/core';
|
} from '@ngx-translate/core';
|
||||||
import {
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
map,
|
map,
|
||||||
Observable,
|
Observable,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { CollectionDataService } from 'src/app/core/data/collection-data.service';
|
import { CollectionDataService } from 'src/app/core/data/collection-data.service';
|
||||||
import { CommunityDataService } from 'src/app/core/data/community-data.service';
|
import { CommunityDataService } from 'src/app/core/data/community-data.service';
|
||||||
|
import { AuthorizationDataService } from 'src/app/core/data/feature-authorization/authorization-data.service';
|
||||||
import { MetadataFieldDataService } from 'src/app/core/data/metadata-field-data.service';
|
import { MetadataFieldDataService } from 'src/app/core/data/metadata-field-data.service';
|
||||||
import { MetadataSchemaDataService } from 'src/app/core/data/metadata-schema-data.service';
|
import { MetadataSchemaDataService } from 'src/app/core/data/metadata-schema-data.service';
|
||||||
|
import { ScriptDataService } from 'src/app/core/data/processes/script-data.service';
|
||||||
import { RestRequestMethod } from 'src/app/core/data/rest-request-method';
|
import { RestRequestMethod } from 'src/app/core/data/rest-request-method';
|
||||||
import { DspaceRestService } from 'src/app/core/dspace-rest/dspace-rest.service';
|
import { DspaceRestService } from 'src/app/core/dspace-rest/dspace-rest.service';
|
||||||
import { RawRestResponse } from 'src/app/core/dspace-rest/raw-rest-response.model';
|
import { RawRestResponse } from 'src/app/core/dspace-rest/raw-rest-response.model';
|
||||||
@@ -36,10 +39,12 @@ import { Collection } from 'src/app/core/shared/collection.model';
|
|||||||
import { Community } from 'src/app/core/shared/community.model';
|
import { Community } from 'src/app/core/shared/community.model';
|
||||||
import { getFirstSucceededRemoteListPayload } from 'src/app/core/shared/operators';
|
import { getFirstSucceededRemoteListPayload } from 'src/app/core/shared/operators';
|
||||||
import { isEmpty } from 'src/app/shared/empty.util';
|
import { isEmpty } from 'src/app/shared/empty.util';
|
||||||
|
import { ThemedLoadingComponent } from 'src/app/shared/loading/themed-loading.component';
|
||||||
import { environment } from 'src/environments/environment';
|
import { environment } from 'src/environments/environment';
|
||||||
|
|
||||||
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
|
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
|
||||||
import { FiltersComponent } from '../filters-section/filters-section.component';
|
import { FiltersComponent } from '../filters-section/filters-section.component';
|
||||||
|
import { FilteredItemsExportCsvComponent } from './filtered-items-export-csv/filtered-items-export-csv.component';
|
||||||
import {
|
import {
|
||||||
FilteredItem,
|
FilteredItem,
|
||||||
FilteredItems,
|
FilteredItems,
|
||||||
@@ -62,12 +67,19 @@ import { QueryPredicate } from './query-predicate.model';
|
|||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
FiltersComponent,
|
FiltersComponent,
|
||||||
BtnDisabledDirective,
|
BtnDisabledDirective,
|
||||||
|
FilteredItemsExportCsvComponent,
|
||||||
|
ThemedLoadingComponent,
|
||||||
],
|
],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
})
|
})
|
||||||
export class FilteredItemsComponent implements OnInit {
|
export class FilteredItemsComponent implements OnInit {
|
||||||
|
|
||||||
collections: OptionVO[];
|
collections: OptionVO[];
|
||||||
|
/**
|
||||||
|
* A Boolean representing if loading the list of collections is pending
|
||||||
|
*/
|
||||||
|
loadingCollections$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
presetQueries: PresetQuery[];
|
presetQueries: PresetQuery[];
|
||||||
metadataFields: OptionVO[];
|
metadataFields: OptionVO[];
|
||||||
metadataFieldsWithAny: OptionVO[];
|
metadataFieldsWithAny: OptionVO[];
|
||||||
@@ -79,6 +91,10 @@ export class FilteredItemsComponent implements OnInit {
|
|||||||
results: FilteredItems = new FilteredItems();
|
results: FilteredItems = new FilteredItems();
|
||||||
results$: Observable<FilteredItem[]>;
|
results$: Observable<FilteredItem[]>;
|
||||||
@ViewChild('acc') accordionComponent: NgbAccordion;
|
@ViewChild('acc') accordionComponent: NgbAccordion;
|
||||||
|
/**
|
||||||
|
* Observable used to determine whether CSV export is enabled
|
||||||
|
*/
|
||||||
|
csvExportEnabled$: Observable<boolean>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private communityService: CommunityDataService,
|
private communityService: CommunityDataService,
|
||||||
@@ -86,6 +102,8 @@ export class FilteredItemsComponent implements OnInit {
|
|||||||
private metadataSchemaService: MetadataSchemaDataService,
|
private metadataSchemaService: MetadataSchemaDataService,
|
||||||
private metadataFieldService: MetadataFieldDataService,
|
private metadataFieldService: MetadataFieldDataService,
|
||||||
private translateService: TranslateService,
|
private translateService: TranslateService,
|
||||||
|
private scriptDataService: ScriptDataService,
|
||||||
|
private authorizationDataService: AuthorizationDataService,
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
private restService: DspaceRestService) {}
|
private restService: DspaceRestService) {}
|
||||||
|
|
||||||
@@ -100,6 +118,8 @@ export class FilteredItemsComponent implements OnInit {
|
|||||||
new QueryPredicate().toFormGroup(this.formBuilder),
|
new QueryPredicate().toFormGroup(this.formBuilder),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
this.csvExportEnabled$ = FilteredItemsExportCsvComponent.csvExportEnabled(this.scriptDataService, this.authorizationDataService);
|
||||||
|
|
||||||
this.queryForm = this.formBuilder.group({
|
this.queryForm = this.formBuilder.group({
|
||||||
collections: this.formBuilder.control([''], []),
|
collections: this.formBuilder.control([''], []),
|
||||||
presetQuery: this.formBuilder.control('new', []),
|
presetQuery: this.formBuilder.control('new', []),
|
||||||
@@ -111,6 +131,7 @@ export class FilteredItemsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadCollections(): void {
|
loadCollections(): void {
|
||||||
|
this.loadingCollections$.next(true);
|
||||||
this.collections = [];
|
this.collections = [];
|
||||||
const wholeRepo$ = this.translateService.stream('admin.reports.items.wholeRepo');
|
const wholeRepo$ = this.translateService.stream('admin.reports.items.wholeRepo');
|
||||||
this.collections.push(OptionVO.collectionLoc('', wholeRepo$));
|
this.collections.push(OptionVO.collectionLoc('', wholeRepo$));
|
||||||
@@ -132,6 +153,7 @@ export class FilteredItemsComponent implements OnInit {
|
|||||||
const collVO = OptionVO.collection(collection.uuid, '–' + collection.name);
|
const collVO = OptionVO.collection(collection.uuid, '–' + collection.name);
|
||||||
this.collections.push(collVO);
|
this.collections.push(collVO);
|
||||||
});
|
});
|
||||||
|
this.loadingCollections$.next(false);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -167,10 +189,10 @@ export class FilteredItemsComponent implements OnInit {
|
|||||||
QueryPredicate.of('dc.description.provenance', QueryPredicate.DOES_NOT_MATCH, '^.*No\. of bitstreams(.|\r|\n|\r\n)*\.(PDF|pdf|DOC|doc|PPT|ppt|DOCX|docx|PPTX|pptx).*$'),
|
QueryPredicate.of('dc.description.provenance', QueryPredicate.DOES_NOT_MATCH, '^.*No\. of bitstreams(.|\r|\n|\r\n)*\.(PDF|pdf|DOC|doc|PPT|ppt|DOCX|docx|PPTX|pptx).*$'),
|
||||||
]),
|
]),
|
||||||
PresetQuery.of('q9', 'admin.reports.items.preset.hasEmptyMetadata', [
|
PresetQuery.of('q9', 'admin.reports.items.preset.hasEmptyMetadata', [
|
||||||
QueryPredicate.of('*', QueryPredicate.MATCHES, '^\s*$'),
|
QueryPredicate.of('*', QueryPredicate.MATCHES, '^\\s*$'),
|
||||||
]),
|
]),
|
||||||
PresetQuery.of('q10', 'admin.reports.items.preset.hasUnbreakingDataInDescription', [
|
PresetQuery.of('q10', 'admin.reports.items.preset.hasUnbreakingDataInDescription', [
|
||||||
QueryPredicate.of('dc.description.*', QueryPredicate.MATCHES, '^.*[^\s]{50,}.*$'),
|
QueryPredicate.of('dc.description.*', QueryPredicate.MATCHES, '^.*(\\S){50,}.*$'),
|
||||||
]),
|
]),
|
||||||
PresetQuery.of('q12', 'admin.reports.items.preset.hasXmlEntityInMetadata', [
|
PresetQuery.of('q12', 'admin.reports.items.preset.hasXmlEntityInMetadata', [
|
||||||
QueryPredicate.of('*', QueryPredicate.MATCHES, '^.*&#.*$'),
|
QueryPredicate.of('*', QueryPredicate.MATCHES, '^.*&#.*$'),
|
||||||
@@ -344,13 +366,8 @@ export class FilteredItemsComponent implements OnInit {
|
|||||||
|
|
||||||
const preds = this.queryForm.value.queryPredicates;
|
const preds = this.queryForm.value.queryPredicates;
|
||||||
for (let i = 0; i < preds.length; i++) {
|
for (let i = 0; i < preds.length; i++) {
|
||||||
const field = preds[i].field;
|
const pred = encodeURIComponent(QueryPredicate.toString(preds[i]));
|
||||||
const op = preds[i].operator;
|
params += `&queryPredicates=${pred}`;
|
||||||
const value = preds[i].value;
|
|
||||||
params += `&queryPredicates=${field}:${op}`;
|
|
||||||
if (value) {
|
|
||||||
params += `:${value}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = FiltersComponent.toQueryString(this.queryForm.value.filters);
|
const filters = FiltersComponent.toQueryString(this.queryForm.value.filters);
|
||||||
|
@@ -46,6 +46,16 @@ export class OptionVO {
|
|||||||
subscriber.next(value);
|
subscriber.next(value);
|
||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static toString(obj: any): string {
|
||||||
|
if (obj) {
|
||||||
|
if (obj instanceof OptionVO && obj.id) {
|
||||||
|
return obj.id;
|
||||||
|
}
|
||||||
|
return obj as string;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -29,6 +29,13 @@ export class QueryPredicate {
|
|||||||
return pred;
|
return pred;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static toString(pred: QueryPredicate): string {
|
||||||
|
if (pred.value) {
|
||||||
|
return `${pred.field}:${pred.operator}:${pred.value}`;
|
||||||
|
}
|
||||||
|
return `${pred.field}:${pred.operator}`;
|
||||||
|
}
|
||||||
|
|
||||||
toFormGroup(formBuilder: FormBuilder): FormGroup {
|
toFormGroup(formBuilder: FormBuilder): FormGroup {
|
||||||
return formBuilder.group({
|
return formBuilder.group({
|
||||||
field: new FormControl(this.field),
|
field: new FormControl(this.field),
|
||||||
|
@@ -10,7 +10,6 @@ import { TranslateModule } from '@ngx-translate/core';
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { AuthService } from '../../../../../core/auth/auth.service';
|
import { AuthService } from '../../../../../core/auth/auth.service';
|
||||||
import { AccessStatusDataService } from '../../../../../core/data/access-status-data.service';
|
|
||||||
import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
|
import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
|
||||||
import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service';
|
import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service';
|
||||||
import { RemoteData } from '../../../../../core/data/remote-data';
|
import { RemoteData } from '../../../../../core/data/remote-data';
|
||||||
@@ -22,7 +21,6 @@ import { ViewMode } from '../../../../../core/shared/view-mode.model';
|
|||||||
import { mockTruncatableService } from '../../../../../shared/mocks/mock-trucatable.service';
|
import { mockTruncatableService } from '../../../../../shared/mocks/mock-trucatable.service';
|
||||||
import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock';
|
import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock';
|
||||||
import { CollectionElementLinkType } from '../../../../../shared/object-collection/collection-element-link.type';
|
import { CollectionElementLinkType } from '../../../../../shared/object-collection/collection-element-link.type';
|
||||||
import { AccessStatusObject } from '../../../../../shared/object-collection/shared/badges/access-status-badge/access-status.model';
|
|
||||||
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
|
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
|
||||||
import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils';
|
import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils';
|
||||||
import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub';
|
import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub';
|
||||||
@@ -44,12 +42,6 @@ describe('ItemAdminSearchResultGridElementComponent', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockAccessStatusDataService = {
|
|
||||||
findAccessStatusFor(item: Item): Observable<RemoteData<AccessStatusObject>> {
|
|
||||||
return createSuccessfulRemoteDataObject$(new AccessStatusObject());
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockThemeService = getMockThemeService();
|
const mockThemeService = getMockThemeService();
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
@@ -74,7 +66,6 @@ describe('ItemAdminSearchResultGridElementComponent', () => {
|
|||||||
{ provide: TruncatableService, useValue: mockTruncatableService },
|
{ provide: TruncatableService, useValue: mockTruncatableService },
|
||||||
{ provide: BitstreamDataService, useValue: mockBitstreamDataService },
|
{ provide: BitstreamDataService, useValue: mockBitstreamDataService },
|
||||||
{ provide: ThemeService, useValue: mockThemeService },
|
{ provide: ThemeService, useValue: mockThemeService },
|
||||||
{ provide: AccessStatusDataService, useValue: mockAccessStatusDataService },
|
|
||||||
{ provide: AuthService, useClass: AuthServiceStub },
|
{ provide: AuthService, useClass: AuthServiceStub },
|
||||||
{ provide: FileService, useClass: FileServiceStub },
|
{ provide: FileService, useClass: FileServiceStub },
|
||||||
{ provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub },
|
{ provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub },
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
[ngClass]="{ disabled: isDisabled }"
|
[ngClass]="{ disabled: isDisabled }"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
[attr.aria-disabled]="isDisabled"
|
[attr.aria-disabled]="isDisabled"
|
||||||
[attr.aria-labelledby]="adminMenuSectionTitleId(section.id)"
|
[attr.aria-labelledby]="adminMenuSectionTitleAccessibilityHandle(section)"
|
||||||
[routerLink]="itemModel.link"
|
[routerLink]="itemModel.link"
|
||||||
(keyup.space)="navigate($event)"
|
(keyup.space)="navigate($event)"
|
||||||
(keyup.enter)="navigate($event)"
|
(keyup.enter)="navigate($event)"
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="sidebar-collapsible-element-outer-wrapper">
|
<div class="sidebar-collapsible-element-outer-wrapper">
|
||||||
<div class="sidebar-collapsible-element-inner-wrapper sidebar-item">
|
<div class="sidebar-collapsible-element-inner-wrapper sidebar-item">
|
||||||
<span [id]="adminMenuSectionTitleId(section.id)" [attr.data-test]="adminMenuSectionTitleId(section.id) | dsBrowserOnly">
|
<span [id]="adminMenuSectionTitleAccessibilityHandle(section)" [attr.data-test]="adminMenuSectionTitleAccessibilityHandle(section) | dsBrowserOnly">
|
||||||
{{itemModel.text | translate}}
|
{{itemModel.text | translate}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -16,7 +16,7 @@ import { MenuService } from '../../../shared/menu/menu.service';
|
|||||||
import { MenuID } from '../../../shared/menu/menu-id.model';
|
import { MenuID } from '../../../shared/menu/menu-id.model';
|
||||||
import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
|
import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
|
||||||
import { MenuSection } from '../../../shared/menu/menu-section.model';
|
import { MenuSection } from '../../../shared/menu/menu-section.model';
|
||||||
import { MenuSectionComponent } from '../../../shared/menu/menu-section/menu-section.component';
|
import { AbstractMenuSectionComponent } from '../../../shared/menu/menu-section/abstract-menu-section.component';
|
||||||
import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
|
import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +30,7 @@ import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
|
|||||||
imports: [NgClass, RouterLink, TranslateModule, BrowserOnlyPipe],
|
imports: [NgClass, RouterLink, TranslateModule, BrowserOnlyPipe],
|
||||||
|
|
||||||
})
|
})
|
||||||
export class AdminSidebarSectionComponent extends MenuSectionComponent implements OnInit {
|
export class AdminSidebarSectionComponent extends AbstractMenuSectionComponent implements OnInit {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This section resides in the Admin Sidebar
|
* This section resides in the Admin Sidebar
|
||||||
@@ -44,16 +44,17 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement
|
|||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('sectionDataProvider') menuSection: MenuSection,
|
@Inject('sectionDataProvider') protected section: MenuSection,
|
||||||
protected menuService: MenuService,
|
protected menuService: MenuService,
|
||||||
protected injector: Injector,
|
protected injector: Injector,
|
||||||
protected router: Router,
|
protected router: Router,
|
||||||
) {
|
) {
|
||||||
super(menuSection, menuService, injector);
|
super(menuService, injector);
|
||||||
this.itemModel = menuSection.model as LinkMenuItemModel;
|
this.itemModel = section.model as LinkMenuItemModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
// todo: should support all menu entries?
|
||||||
this.isDisabled = this.itemModel?.disabled || isEmpty(this.itemModel?.link);
|
this.isDisabled = this.itemModel?.disabled || isEmpty(this.itemModel?.link);
|
||||||
super.ngOnInit();
|
super.ngOnInit();
|
||||||
}
|
}
|
||||||
@@ -65,11 +66,13 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
adminMenuSectionId(sectionId: string) {
|
adminMenuSectionId(section: MenuSection) {
|
||||||
return `admin-menu-section-${sectionId}`;
|
const accessibilityHandle = section.accessibilityHandle ?? section.id;
|
||||||
|
return `admin-menu-section-${accessibilityHandle}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
adminMenuSectionTitleId(sectionId: string) {
|
adminMenuSectionTitleAccessibilityHandle(section: MenuSection) {
|
||||||
return `admin-menu-section-${sectionId}-title`;
|
const accessibilityHandle = section.accessibilityHandle ?? section.id;
|
||||||
|
return `admin-menu-section-${accessibilityHandle}-title`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,6 @@
|
|||||||
<div [ngClass]="{'expanded': (isExpanded$ | async)}"
|
@if (hasSubSections$ | async) {
|
||||||
|
<div
|
||||||
|
[ngClass]="{'expanded': (isExpanded$ | async)}"
|
||||||
[@bgColor]="{
|
[@bgColor]="{
|
||||||
value: ((isExpanded$ | async) ? 'endBackground' : 'startBackground'),
|
value: ((isExpanded$ | async) ? 'endBackground' : 'startBackground'),
|
||||||
params: {endColor: (sidebarActiveBg$ | async)}
|
params: {endColor: (sidebarActiveBg$ | async)}
|
||||||
@@ -6,7 +8,7 @@
|
|||||||
<a class="sidebar-section-wrapper"
|
<a class="sidebar-section-wrapper"
|
||||||
role="menuitem" tabindex="0"
|
role="menuitem" tabindex="0"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
[attr.aria-controls]="adminMenuSectionId(section.id)"
|
[attr.aria-controls]="adminMenuSectionId(section)"
|
||||||
[attr.aria-expanded]="isExpanded$ | async"
|
[attr.aria-expanded]="isExpanded$ | async"
|
||||||
[attr.aria-label]="('menu.section.toggle.' + section.id) | translate"
|
[attr.aria-label]="('menu.section.toggle.' + section.id) | translate"
|
||||||
[class.disabled]="section.model?.disabled"
|
[class.disabled]="section.model?.disabled"
|
||||||
@@ -15,11 +17,11 @@
|
|||||||
href="javascript:void(0);"
|
href="javascript:void(0);"
|
||||||
>
|
>
|
||||||
<div class="sidebar-fixed-element-wrapper" data-test="sidebar-section-icon" aria-hidden="true">
|
<div class="sidebar-fixed-element-wrapper" data-test="sidebar-section-icon" aria-hidden="true">
|
||||||
<i class="fas fa-{{section.icon}} fa-fw"></i>
|
<i class="fas fa-{{section.icon ?? 'notdef'}} fa-fw"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-collapsible-element-outer-wrapper">
|
<div class="sidebar-collapsible-element-outer-wrapper">
|
||||||
<div class="sidebar-collapsible-element-inner-wrapper sidebar-item toggler-wrapper">
|
<div class="sidebar-collapsible-element-inner-wrapper sidebar-item toggler-wrapper">
|
||||||
<span [id]="adminMenuSectionTitleId(section.id)" [attr.data-test]="adminMenuSectionTitleId(section.id) | dsBrowserOnly">
|
<span [id]="adminMenuSectionTitleAccessibilityHandle(section)" [attr.data-test]="adminMenuSectionTitleAccessibilityHandle(section) | dsBrowserOnly">
|
||||||
<ng-container
|
<ng-container
|
||||||
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
|
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
|
||||||
</span>
|
</span>
|
||||||
@@ -34,7 +36,7 @@
|
|||||||
<div class="sidebar-fixed-element-wrapper"></div>
|
<div class="sidebar-fixed-element-wrapper"></div>
|
||||||
<div class="sidebar-collapsible-element-outer-wrapper">
|
<div class="sidebar-collapsible-element-outer-wrapper">
|
||||||
<div class="sidebar-collapsible-element-inner-wrapper">
|
<div class="sidebar-collapsible-element-inner-wrapper">
|
||||||
<div class="sidebar-sub-level-item-list" role="menu" [id]="adminMenuSectionId(section.id)" [attr.aria-label]="('menu.section.' + section.id) | translate">
|
<div class="sidebar-sub-level-item-list" role="menu" [id]="adminMenuSectionId(section)" [attr.aria-label]="('menu.section.' + section.id) | translate">
|
||||||
@for (subSection of (subSections$ | async); track subSection) {
|
@for (subSection of (subSections$ | async); track subSection) {
|
||||||
<div class="sidebar-item">
|
<div class="sidebar-item">
|
||||||
<ng-container
|
<ng-container
|
||||||
@@ -47,3 +49,4 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
@@ -11,6 +11,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
|||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
|
|
||||||
import { MenuService } from '../../../shared/menu/menu.service';
|
import { MenuService } from '../../../shared/menu/menu.service';
|
||||||
|
import { MenuItemModels } from '../../../shared/menu/menu-section.model';
|
||||||
import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service';
|
import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service';
|
||||||
import { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service.stub';
|
import { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service.stub';
|
||||||
import { MenuServiceStub } from '../../../shared/testing/menu-service.stub';
|
import { MenuServiceStub } from '../../../shared/testing/menu-service.stub';
|
||||||
@@ -22,6 +23,9 @@ describe('ExpandableAdminSidebarSectionComponent', () => {
|
|||||||
let fixture: ComponentFixture<ExpandableAdminSidebarSectionComponent>;
|
let fixture: ComponentFixture<ExpandableAdminSidebarSectionComponent>;
|
||||||
const menuService = new MenuServiceStub();
|
const menuService = new MenuServiceStub();
|
||||||
const iconString = 'test';
|
const iconString = 'test';
|
||||||
|
|
||||||
|
|
||||||
|
describe('when there are subsections', () => {
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [NoopAnimationsModule, TranslateModule.forRoot(), ExpandableAdminSidebarSectionComponent, TestComponent],
|
imports: [NoopAnimationsModule, TranslateModule.forRoot(), ExpandableAdminSidebarSectionComponent, TestComponent],
|
||||||
@@ -35,7 +39,11 @@ describe('ExpandableAdminSidebarSectionComponent', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([]));
|
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([{
|
||||||
|
id: 'test',
|
||||||
|
visible: true,
|
||||||
|
model: {} as MenuItemModels,
|
||||||
|
}]));
|
||||||
fixture = TestBed.createComponent(ExpandableAdminSidebarSectionComponent);
|
fixture = TestBed.createComponent(ExpandableAdminSidebarSectionComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
|
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
|
||||||
@@ -67,6 +75,41 @@ describe('ExpandableAdminSidebarSectionComponent', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('when there are no subsections', () => {
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [NoopAnimationsModule, TranslateModule.forRoot(), ExpandableAdminSidebarSectionComponent, TestComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: 'sectionDataProvider', useValue: { icon: iconString, model: {} } },
|
||||||
|
{ provide: MenuService, useValue: menuService },
|
||||||
|
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
|
||||||
|
{ provide: Router, useValue: new RouterStub() },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([]));
|
||||||
|
fixture = TestBed.createComponent(ExpandableAdminSidebarSectionComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not contain a section', () => {
|
||||||
|
const icon = fixture.debugElement.query(By.css('.shortcut-icon'));
|
||||||
|
expect(icon).toBeNull();
|
||||||
|
const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-section'));
|
||||||
|
expect(sidebarToggler).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// declare a test component
|
// declare a test component
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-test-cmp',
|
selector: 'ds-test-cmp',
|
||||||
|
@@ -20,8 +20,10 @@ import { map } from 'rxjs/operators';
|
|||||||
import { bgColor } from '../../../shared/animations/bgColor';
|
import { bgColor } from '../../../shared/animations/bgColor';
|
||||||
import { rotate } from '../../../shared/animations/rotate';
|
import { rotate } from '../../../shared/animations/rotate';
|
||||||
import { slide } from '../../../shared/animations/slide';
|
import { slide } from '../../../shared/animations/slide';
|
||||||
|
import { isNotEmpty } from '../../../shared/empty.util';
|
||||||
import { MenuService } from '../../../shared/menu/menu.service';
|
import { MenuService } from '../../../shared/menu/menu.service';
|
||||||
import { MenuID } from '../../../shared/menu/menu-id.model';
|
import { MenuID } from '../../../shared/menu/menu-id.model';
|
||||||
|
import { MenuSection } from '../../../shared/menu/menu-section.model';
|
||||||
import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service';
|
import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service';
|
||||||
import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
|
import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
|
||||||
import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sidebar-section.component';
|
import { AdminSidebarSectionComponent } from '../admin-sidebar-section/admin-sidebar-section.component';
|
||||||
@@ -65,14 +67,20 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
|
|||||||
*/
|
*/
|
||||||
isExpanded$: Observable<boolean>;
|
isExpanded$: Observable<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits true when the top section has subsections, else emits false
|
||||||
|
*/
|
||||||
|
hasSubSections$: Observable<boolean>;
|
||||||
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('sectionDataProvider') menuSection,
|
@Inject('sectionDataProvider') protected section: MenuSection,
|
||||||
protected menuService: MenuService,
|
protected menuService: MenuService,
|
||||||
private variableService: CSSVariableService,
|
private variableService: CSSVariableService,
|
||||||
protected injector: Injector,
|
protected injector: Injector,
|
||||||
protected router: Router,
|
protected router: Router,
|
||||||
) {
|
) {
|
||||||
super(menuSection, menuService, injector, router);
|
super(section, menuService, injector, router);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,6 +88,9 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
|
|||||||
*/
|
*/
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
super.ngOnInit();
|
super.ngOnInit();
|
||||||
|
this.hasSubSections$ = this.subSections$.pipe(
|
||||||
|
map((subSections) => isNotEmpty(subSections)),
|
||||||
|
);
|
||||||
this.sidebarActiveBg$ = this.variableService.getVariable('--ds-admin-sidebar-active-bg');
|
this.sidebarActiveBg$ = this.variableService.getVariable('--ds-admin-sidebar-active-bg');
|
||||||
this.isSidebarCollapsed$ = this.menuService.isMenuCollapsed(this.menuID);
|
this.isSidebarCollapsed$ = this.menuService.isMenuCollapsed(this.menuID);
|
||||||
this.isSidebarPreviewCollapsed$ = this.menuService.isMenuPreviewCollapsed(this.menuID);
|
this.isSidebarPreviewCollapsed$ = this.menuService.isMenuPreviewCollapsed(this.menuID);
|
||||||
|
@@ -34,7 +34,6 @@ import { forgotPasswordCheckGuard } from './core/rest-property/forgot-password-c
|
|||||||
import { ServerCheckGuard } from './core/server-check/server-check.guard';
|
import { ServerCheckGuard } from './core/server-check/server-check.guard';
|
||||||
import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component';
|
import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component';
|
||||||
import { ITEM_MODULE_PATH } from './item-page/item-page-routing-paths';
|
import { ITEM_MODULE_PATH } from './item-page/item-page-routing-paths';
|
||||||
import { menuResolver } from './menuResolver';
|
|
||||||
import { provideSuggestionNotificationsState } from './notifications/provide-suggestion-notifications-state';
|
import { provideSuggestionNotificationsState } from './notifications/provide-suggestion-notifications-state';
|
||||||
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
|
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
|
||||||
import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component';
|
import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component';
|
||||||
@@ -50,7 +49,6 @@ export const APP_ROUTES: Route[] = [
|
|||||||
path: '',
|
path: '',
|
||||||
canActivate: [authBlockingGuard],
|
canActivate: [authBlockingGuard],
|
||||||
canActivateChild: [ServerCheckGuard],
|
canActivateChild: [ServerCheckGuard],
|
||||||
resolve: [menuResolver],
|
|
||||||
children: [
|
children: [
|
||||||
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
||||||
{
|
{
|
||||||
@@ -263,6 +261,20 @@ export const APP_ROUTES: Route[] = [
|
|||||||
.then((m) => m.ROUTES),
|
.then((m) => m.ROUTES),
|
||||||
canActivate: [authenticatedGuard],
|
canActivate: [authenticatedGuard],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'external-login/:token',
|
||||||
|
loadChildren: () => import('./external-login-page/external-login-routes').then((m) => m.ROUTES),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'review-account/:token',
|
||||||
|
loadChildren: () => import('./external-login-review-account-info-page/external-login-review-account-info-page-routes')
|
||||||
|
.then((m) => m.ROUTES),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'email-confirmation',
|
||||||
|
loadChildren: () => import('./external-login-email-confirmation-page/external-login-email-confirmation-page-routes')
|
||||||
|
.then((m) => m.ROUTES),
|
||||||
|
},
|
||||||
{ path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent },
|
{ path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@@ -35,6 +35,26 @@ export function getBitstreamRequestACopyRoute(item, bitstream): { routerLink: st
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a bitstream download route with an access token (to provide direct access to a user) added as a query parameter
|
||||||
|
* @param bitstream the bitstream to download
|
||||||
|
* @param accessToken the access token, which should match an access_token in the requestitem table
|
||||||
|
*/
|
||||||
|
export function getBitstreamDownloadWithAccessTokenRoute(bitstream, accessToken): { routerLink: string, queryParams: any } {
|
||||||
|
const url = new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString();
|
||||||
|
const options = {
|
||||||
|
routerLink: url,
|
||||||
|
queryParams: {},
|
||||||
|
};
|
||||||
|
// Only add the access token if it is not empty, otherwise keep valid empty query parameters
|
||||||
|
if (hasValue(accessToken)) {
|
||||||
|
options.queryParams = { accessToken: accessToken };
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COAR_NOTIFY_SUPPORT = 'coar-notify-support';
|
||||||
|
|
||||||
export const HOME_PAGE_PATH = 'home';
|
export const HOME_PAGE_PATH = 'home';
|
||||||
|
|
||||||
export function getHomePageRoute() {
|
export function getHomePageRoute() {
|
||||||
|
@@ -10,6 +10,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
NoPreloading,
|
NoPreloading,
|
||||||
provideRouter,
|
provideRouter,
|
||||||
|
withComponentInputBinding,
|
||||||
withEnabledBlockingInitialNavigation,
|
withEnabledBlockingInitialNavigation,
|
||||||
withInMemoryScrolling,
|
withInMemoryScrolling,
|
||||||
withPreloading,
|
withPreloading,
|
||||||
@@ -38,6 +39,7 @@ import { StoreDevModules } from '../config/store/devtools';
|
|||||||
import { environment } from '../environments/environment';
|
import { environment } from '../environments/environment';
|
||||||
import { EagerThemesModule } from '../themes/eager-themes.module';
|
import { EagerThemesModule } from '../themes/eager-themes.module';
|
||||||
import { appEffects } from './app.effects';
|
import { appEffects } from './app.effects';
|
||||||
|
import { MENUS } from './app.menus';
|
||||||
import {
|
import {
|
||||||
appMetaReducers,
|
appMetaReducers,
|
||||||
debugMetaReducers,
|
debugMetaReducers,
|
||||||
@@ -64,6 +66,7 @@ import {
|
|||||||
import { ClientCookieService } from './core/services/client-cookie.service';
|
import { ClientCookieService } from './core/services/client-cookie.service';
|
||||||
import { ListableModule } from './core/shared/listable.module';
|
import { ListableModule } from './core/shared/listable.module';
|
||||||
import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor';
|
import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor';
|
||||||
|
import { LOGIN_METHOD_FOR_DECORATOR_MAP } from './external-log-in/decorators/external-log-in.methods-decorator';
|
||||||
import { RootModule } from './root.module';
|
import { RootModule } from './root.module';
|
||||||
import { AUTH_METHOD_FOR_DECORATOR_MAP } from './shared/log-in/methods/log-in.methods-decorator';
|
import { AUTH_METHOD_FOR_DECORATOR_MAP } from './shared/log-in/methods/log-in.methods-decorator';
|
||||||
import { METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP } from './shared/metadata-representation/metadata-representation.decorator';
|
import { METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP } from './shared/metadata-representation/metadata-representation.decorator';
|
||||||
@@ -109,6 +112,7 @@ export const commonAppConfig: ApplicationConfig = {
|
|||||||
withInMemoryScrolling(APP_ROUTING_SCROLL_CONF),
|
withInMemoryScrolling(APP_ROUTING_SCROLL_CONF),
|
||||||
withEnabledBlockingInitialNavigation(),
|
withEnabledBlockingInitialNavigation(),
|
||||||
withPreloading(NoPreloading),
|
withPreloading(NoPreloading),
|
||||||
|
withComponentInputBinding(),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
provide: APP_BASE_HREF,
|
provide: APP_BASE_HREF,
|
||||||
@@ -156,6 +160,10 @@ export const commonAppConfig: ApplicationConfig = {
|
|||||||
},
|
},
|
||||||
// register the dynamic matcher used by form. MUST be provided by the app module
|
// register the dynamic matcher used by form. MUST be provided by the app module
|
||||||
...DYNAMIC_MATCHER_PROVIDERS,
|
...DYNAMIC_MATCHER_PROVIDERS,
|
||||||
|
|
||||||
|
// DI-composable menus
|
||||||
|
...MENUS,
|
||||||
|
|
||||||
provideCore(),
|
provideCore(),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -163,6 +171,7 @@ export const commonAppConfig: ApplicationConfig = {
|
|||||||
|
|
||||||
/* Use models object so all decorators are actually called */
|
/* Use models object so all decorators are actually called */
|
||||||
const modelList = models;
|
const modelList = models;
|
||||||
|
const loginMethodForDecoratorMap = LOGIN_METHOD_FOR_DECORATOR_MAP;
|
||||||
const workflowTasks = WORKFLOW_TASK_OPTION_DECORATOR_MAP;
|
const workflowTasks = WORKFLOW_TASK_OPTION_DECORATOR_MAP;
|
||||||
const advancedWorfklowTasks = ADVANCED_WORKFLOW_TASK_OPTION_DECORATOR_MAP;
|
const advancedWorfklowTasks = ADVANCED_WORKFLOW_TASK_OPTION_DECORATOR_MAP;
|
||||||
const metadataRepresentations = METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP;
|
const metadataRepresentations = METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP;
|
||||||
|
101
src/app/app.menus.ts
Normal file
101
src/app/app.menus.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* The contents of this file are subject to the license and copyright
|
||||||
|
* detailed in the LICENSE and NOTICE files at the root of the source
|
||||||
|
* tree and available online at
|
||||||
|
*
|
||||||
|
* http://www.dspace.org/license/
|
||||||
|
*/
|
||||||
|
import { buildMenuStructure } from './shared/menu/menu.structure';
|
||||||
|
import { MenuID } from './shared/menu/menu-id.model';
|
||||||
|
import { MenuRoute } from './shared/menu/menu-route.model';
|
||||||
|
import { AccessControlMenuProvider } from './shared/menu/providers/access-control.menu';
|
||||||
|
import { AdminSearchMenuProvider } from './shared/menu/providers/admin-search.menu';
|
||||||
|
import { BrowseMenuProvider } from './shared/menu/providers/browse.menu';
|
||||||
|
import { CoarNotifyMenuProvider } from './shared/menu/providers/coar-notify.menu';
|
||||||
|
import { SubscribeMenuProvider } from './shared/menu/providers/comcol-subscribe.menu';
|
||||||
|
import { CommunityListMenuProvider } from './shared/menu/providers/community-list.menu';
|
||||||
|
import { CreateReportMenuProvider } from './shared/menu/providers/create-report.menu';
|
||||||
|
import { CurationMenuProvider } from './shared/menu/providers/curation.menu';
|
||||||
|
import { DSpaceObjectEditMenuProvider } from './shared/menu/providers/dso-edit.menu';
|
||||||
|
import { DsoOptionMenuProvider } from './shared/menu/providers/dso-option.menu';
|
||||||
|
import { EditMenuProvider } from './shared/menu/providers/edit.menu';
|
||||||
|
import { ExportMenuProvider } from './shared/menu/providers/export.menu';
|
||||||
|
import { HealthMenuProvider } from './shared/menu/providers/health.menu';
|
||||||
|
import { ImportMenuProvider } from './shared/menu/providers/import.menu';
|
||||||
|
import { ClaimMenuProvider } from './shared/menu/providers/item-claim.menu';
|
||||||
|
import { OrcidMenuProvider } from './shared/menu/providers/item-orcid.menu';
|
||||||
|
import { VersioningMenuProvider } from './shared/menu/providers/item-versioning.menu';
|
||||||
|
import { NewMenuProvider } from './shared/menu/providers/new.menu';
|
||||||
|
import { NotificationsMenuProvider } from './shared/menu/providers/notifications.menu';
|
||||||
|
import { ProcessesMenuProvider } from './shared/menu/providers/processes.menu';
|
||||||
|
import { RegistriesMenuProvider } from './shared/menu/providers/registries.menu';
|
||||||
|
import { StatisticsMenuProvider } from './shared/menu/providers/statistics.menu';
|
||||||
|
import { SystemWideAlertMenuProvider } from './shared/menu/providers/system-wide-alert.menu';
|
||||||
|
import { WithdrawnReinstateItemMenuProvider } from './shared/menu/providers/withdrawn-reinstate-item.menu';
|
||||||
|
import { WorkflowMenuProvider } from './shared/menu/providers/workflow.menu';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents and builds the menu structure for the three available menus (public navbar, admin sidebar and the dso edit
|
||||||
|
* menus).
|
||||||
|
* The structure consists of a list of menu IDs with each of them having a list of providers that will create the
|
||||||
|
* sections to be part of the menu matching the ID.
|
||||||
|
*
|
||||||
|
* The following menu groups are present in this structure:
|
||||||
|
* - `MenuID.PUBLIC`: Defines menus accessible by the public in the navigation bar.
|
||||||
|
* - `MenuID.ADMIN`: Defines menus for administrative users in the sidebar.
|
||||||
|
* - `MenuID.DSO_EDIT`: Defines dynamic menu options for DSpace Objects that will be present on the DSpace Object's page.
|
||||||
|
*
|
||||||
|
* To add more menu sections to a menu (public navbar, admin sidebar or the dso edit menus),
|
||||||
|
* a new provider can be added to the list with the corresponding menu ID.
|
||||||
|
*
|
||||||
|
* The configuration supports route-specific menu providers and hierarchically structured menu options.
|
||||||
|
*/
|
||||||
|
export const MENUS = buildMenuStructure({
|
||||||
|
[MenuID.PUBLIC]: [
|
||||||
|
CommunityListMenuProvider,
|
||||||
|
BrowseMenuProvider,
|
||||||
|
StatisticsMenuProvider,
|
||||||
|
],
|
||||||
|
[MenuID.ADMIN]: [
|
||||||
|
NewMenuProvider,
|
||||||
|
EditMenuProvider,
|
||||||
|
ImportMenuProvider,
|
||||||
|
ExportMenuProvider,
|
||||||
|
NotificationsMenuProvider,
|
||||||
|
AccessControlMenuProvider,
|
||||||
|
AdminSearchMenuProvider,
|
||||||
|
CreateReportMenuProvider,
|
||||||
|
RegistriesMenuProvider,
|
||||||
|
CurationMenuProvider,
|
||||||
|
ProcessesMenuProvider,
|
||||||
|
WorkflowMenuProvider,
|
||||||
|
HealthMenuProvider,
|
||||||
|
SystemWideAlertMenuProvider,
|
||||||
|
CoarNotifyMenuProvider,
|
||||||
|
],
|
||||||
|
[MenuID.DSO_EDIT]: [
|
||||||
|
DsoOptionMenuProvider.withSubs([
|
||||||
|
SubscribeMenuProvider.onRoute(
|
||||||
|
MenuRoute.COMMUNITY_PAGE,
|
||||||
|
MenuRoute.COLLECTION_PAGE,
|
||||||
|
),
|
||||||
|
DSpaceObjectEditMenuProvider.onRoute(
|
||||||
|
MenuRoute.COMMUNITY_PAGE,
|
||||||
|
MenuRoute.COLLECTION_PAGE,
|
||||||
|
MenuRoute.ITEM_PAGE,
|
||||||
|
),
|
||||||
|
WithdrawnReinstateItemMenuProvider.onRoute(
|
||||||
|
MenuRoute.ITEM_PAGE,
|
||||||
|
),
|
||||||
|
VersioningMenuProvider.onRoute(
|
||||||
|
MenuRoute.ITEM_PAGE,
|
||||||
|
),
|
||||||
|
OrcidMenuProvider.onRoute(
|
||||||
|
MenuRoute.ITEM_PAGE,
|
||||||
|
),
|
||||||
|
ClaimMenuProvider.onRoute(
|
||||||
|
MenuRoute.ITEM_PAGE,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
});
|
@@ -1,4 +1,7 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import {
|
||||||
|
CommonModule,
|
||||||
|
Location,
|
||||||
|
} from '@angular/common';
|
||||||
import { PLATFORM_ID } from '@angular/core';
|
import { PLATFORM_ID } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
ComponentFixture,
|
ComponentFixture,
|
||||||
@@ -14,6 +17,8 @@ import { of as observableOf } from 'rxjs';
|
|||||||
|
|
||||||
import { getForbiddenRoute } from '../../app-routing-paths';
|
import { getForbiddenRoute } from '../../app-routing-paths';
|
||||||
import { AuthService } from '../../core/auth/auth.service';
|
import { AuthService } from '../../core/auth/auth.service';
|
||||||
|
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||||
|
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
|
||||||
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
|
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
|
||||||
import { SignpostingDataService } from '../../core/data/signposting-data.service';
|
import { SignpostingDataService } from '../../core/data/signposting-data.service';
|
||||||
import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
||||||
@@ -21,6 +26,7 @@ import { ServerResponseService } from '../../core/services/server-response.servi
|
|||||||
import { Bitstream } from '../../core/shared/bitstream.model';
|
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||||
import { FileService } from '../../core/shared/file.service';
|
import { FileService } from '../../core/shared/file.service';
|
||||||
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
|
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
|
||||||
|
import { MatomoService } from '../../statistics/matomo.service';
|
||||||
import { BitstreamDownloadPageComponent } from './bitstream-download-page.component';
|
import { BitstreamDownloadPageComponent } from './bitstream-download-page.component';
|
||||||
|
|
||||||
describe('BitstreamDownloadPageComponent', () => {
|
describe('BitstreamDownloadPageComponent', () => {
|
||||||
@@ -33,10 +39,13 @@ describe('BitstreamDownloadPageComponent', () => {
|
|||||||
let hardRedirectService: HardRedirectService;
|
let hardRedirectService: HardRedirectService;
|
||||||
let activatedRoute;
|
let activatedRoute;
|
||||||
let router;
|
let router;
|
||||||
|
let location: Location;
|
||||||
|
let dsoNameService: DSONameService;
|
||||||
|
|
||||||
let bitstream: Bitstream;
|
let bitstream: Bitstream;
|
||||||
let serverResponseService: jasmine.SpyObj<ServerResponseService>;
|
let serverResponseService: jasmine.SpyObj<ServerResponseService>;
|
||||||
let signpostingDataService: jasmine.SpyObj<SignpostingDataService>;
|
let signpostingDataService: jasmine.SpyObj<SignpostingDataService>;
|
||||||
|
let matomoService: jasmine.SpyObj<MatomoService>;
|
||||||
|
|
||||||
const mocklink = {
|
const mocklink = {
|
||||||
href: 'http://test.org',
|
href: 'http://test.org',
|
||||||
@@ -54,6 +63,7 @@ describe('BitstreamDownloadPageComponent', () => {
|
|||||||
authService = jasmine.createSpyObj('authService', {
|
authService = jasmine.createSpyObj('authService', {
|
||||||
isAuthenticated: observableOf(true),
|
isAuthenticated: observableOf(true),
|
||||||
setRedirectUrl: {},
|
setRedirectUrl: {},
|
||||||
|
getShortlivedToken: observableOf('token'),
|
||||||
});
|
});
|
||||||
authorizationService = jasmine.createSpyObj('authorizationSerivice', {
|
authorizationService = jasmine.createSpyObj('authorizationSerivice', {
|
||||||
isAuthorized: observableOf(true),
|
isAuthorized: observableOf(true),
|
||||||
@@ -63,9 +73,18 @@ describe('BitstreamDownloadPageComponent', () => {
|
|||||||
retrieveFileDownloadLink: observableOf('content-url-with-headers'),
|
retrieveFileDownloadLink: observableOf('content-url-with-headers'),
|
||||||
});
|
});
|
||||||
|
|
||||||
hardRedirectService = jasmine.createSpyObj('fileService', {
|
hardRedirectService = jasmine.createSpyObj('hardRedirectService', {
|
||||||
redirect: {},
|
redirect: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
location = jasmine.createSpyObj('location', {
|
||||||
|
back: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
dsoNameService = jasmine.createSpyObj('dsoNameService', {
|
||||||
|
getName: 'Test Bitstream',
|
||||||
|
});
|
||||||
|
|
||||||
bitstream = Object.assign(new Bitstream(), {
|
bitstream = Object.assign(new Bitstream(), {
|
||||||
uuid: 'bitstreamUuid',
|
uuid: 'bitstreamUuid',
|
||||||
_links: {
|
_links: {
|
||||||
@@ -73,16 +92,16 @@ describe('BitstreamDownloadPageComponent', () => {
|
|||||||
self: { href: 'bitstream-self-link' },
|
self: { href: 'bitstream-self-link' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
activatedRoute = {
|
activatedRoute = {
|
||||||
data: observableOf({
|
data: observableOf({
|
||||||
bitstream: createSuccessfulRemoteDataObject(
|
bitstream: createSuccessfulRemoteDataObject(bitstream),
|
||||||
bitstream,
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
params: observableOf({
|
params: observableOf({
|
||||||
id: 'testid',
|
id: 'testid',
|
||||||
}),
|
}),
|
||||||
|
queryParams: observableOf({
|
||||||
|
accessToken: undefined,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
router = jasmine.createSpyObj('router', ['navigateByUrl']);
|
router = jasmine.createSpyObj('router', ['navigateByUrl']);
|
||||||
@@ -94,6 +113,11 @@ describe('BitstreamDownloadPageComponent', () => {
|
|||||||
signpostingDataService = jasmine.createSpyObj('SignpostingDataService', {
|
signpostingDataService = jasmine.createSpyObj('SignpostingDataService', {
|
||||||
getLinks: observableOf([mocklink, mocklink2]),
|
getLinks: observableOf([mocklink, mocklink2]),
|
||||||
});
|
});
|
||||||
|
matomoService = jasmine.createSpyObj('MatomoService', {
|
||||||
|
appendVisitorId: observableOf(''),
|
||||||
|
isMatomoEnabled$: observableOf(true),
|
||||||
|
});
|
||||||
|
matomoService.appendVisitorId.and.callFake((link) => observableOf(link));
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTestbed() {
|
function initTestbed() {
|
||||||
@@ -108,7 +132,11 @@ describe('BitstreamDownloadPageComponent', () => {
|
|||||||
{ provide: HardRedirectService, useValue: hardRedirectService },
|
{ provide: HardRedirectService, useValue: hardRedirectService },
|
||||||
{ provide: ServerResponseService, useValue: serverResponseService },
|
{ provide: ServerResponseService, useValue: serverResponseService },
|
||||||
{ provide: SignpostingDataService, useValue: signpostingDataService },
|
{ provide: SignpostingDataService, useValue: signpostingDataService },
|
||||||
|
{ provide: MatomoService, useValue: matomoService },
|
||||||
{ provide: PLATFORM_ID, useValue: 'server' },
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
|
{ provide: Location, useValue: location },
|
||||||
|
{ provide: DSONameService, useValue: dsoNameService },
|
||||||
|
{ provide: ConfigurationDataService, useValue: {} },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
@@ -142,9 +170,11 @@ describe('BitstreamDownloadPageComponent', () => {
|
|||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
it('should redirect to the content link', () => {
|
it('should redirect to the content link', waitForAsync(() => {
|
||||||
|
fixture.whenStable().then(() => {
|
||||||
expect(hardRedirectService.redirect).toHaveBeenCalledWith('bitstream-content-link');
|
expect(hardRedirectService.redirect).toHaveBeenCalledWith('bitstream-content-link');
|
||||||
});
|
});
|
||||||
|
}));
|
||||||
it('should add the signposting links', () => {
|
it('should add the signposting links', () => {
|
||||||
expect(serverResponseService.setHeader).toHaveBeenCalled();
|
expect(serverResponseService.setHeader).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -159,9 +189,11 @@ describe('BitstreamDownloadPageComponent', () => {
|
|||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
it('should redirect to an updated content link', () => {
|
it('should redirect to an updated content link', waitForAsync(() => {
|
||||||
|
fixture.whenStable().then(() => {
|
||||||
expect(hardRedirectService.redirect).toHaveBeenCalledWith('content-url-with-headers');
|
expect(hardRedirectService.redirect).toHaveBeenCalledWith('content-url-with-headers');
|
||||||
});
|
});
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
describe('when the user is not authorized and logged in', () => {
|
describe('when the user is not authorized and logged in', () => {
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
@@ -174,9 +206,11 @@ describe('BitstreamDownloadPageComponent', () => {
|
|||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
it('should navigate to the forbidden route', () => {
|
it('should navigate to the forbidden route', waitForAsync(() => {
|
||||||
|
fixture.whenStable().then(() => {
|
||||||
expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), { skipLocationChange: true });
|
expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), { skipLocationChange: true });
|
||||||
});
|
});
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
describe('when the user is not authorized and not logged in', () => {
|
describe('when the user is not authorized and not logged in', () => {
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
@@ -190,10 +224,50 @@ describe('BitstreamDownloadPageComponent', () => {
|
|||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
it('should navigate to the login page', () => {
|
it('should navigate to the login page', waitForAsync(() => {
|
||||||
|
fixture.whenStable().then(() => {
|
||||||
expect(authService.setRedirectUrl).toHaveBeenCalled();
|
expect(authService.setRedirectUrl).toHaveBeenCalled();
|
||||||
expect(router.navigateByUrl).toHaveBeenCalledWith('login');
|
expect(router.navigateByUrl).toHaveBeenCalledWith('login');
|
||||||
});
|
});
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('when Matomo is enabled', () => {
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
init();
|
||||||
|
(matomoService.appendVisitorId as jasmine.Spy).and.callFake((link) => observableOf(link + '?visitorId=12345'));
|
||||||
|
initTestbed();
|
||||||
|
}));
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BitstreamDownloadPageComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
it('should append visitor ID to the file link', waitForAsync(() => {
|
||||||
|
fixture.whenStable().then(() => {
|
||||||
|
expect(matomoService.appendVisitorId).toHaveBeenCalledWith('content-url-with-headers');
|
||||||
|
expect(hardRedirectService.redirect).toHaveBeenCalledWith('content-url-with-headers?visitorId=12345');
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when Matomo is not enabled', () => {
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
init();
|
||||||
|
(matomoService.isMatomoEnabled$ as jasmine.Spy).and.returnValue(observableOf(false));
|
||||||
|
initTestbed();
|
||||||
|
}));
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BitstreamDownloadPageComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
it('should not append visitor ID to the file link', waitForAsync(() => {
|
||||||
|
fixture.whenStable().then(() => {
|
||||||
|
expect(matomoService.appendVisitorId).not.toHaveBeenCalled();
|
||||||
|
expect(hardRedirectService.redirect).toHaveBeenCalledWith('content-url-with-headers');
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -6,11 +6,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
Inject,
|
Inject,
|
||||||
|
inject,
|
||||||
OnInit,
|
OnInit,
|
||||||
PLATFORM_ID,
|
PLATFORM_ID,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
ActivatedRoute,
|
ActivatedRoute,
|
||||||
|
Params,
|
||||||
Router,
|
Router,
|
||||||
} from '@angular/router';
|
} from '@angular/router';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
@@ -29,6 +31,7 @@ import {
|
|||||||
import { getForbiddenRoute } from '../../app-routing-paths';
|
import { getForbiddenRoute } from '../../app-routing-paths';
|
||||||
import { AuthService } from '../../core/auth/auth.service';
|
import { AuthService } from '../../core/auth/auth.service';
|
||||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||||
|
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
|
||||||
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
|
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
|
||||||
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
|
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
|
||||||
import { RemoteData } from '../../core/data/remote-data';
|
import { RemoteData } from '../../core/data/remote-data';
|
||||||
@@ -44,6 +47,7 @@ import {
|
|||||||
hasValue,
|
hasValue,
|
||||||
isNotEmpty,
|
isNotEmpty,
|
||||||
} from '../../shared/empty.util';
|
} from '../../shared/empty.util';
|
||||||
|
import { MatomoService } from '../../statistics/matomo.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-bitstream-download-page',
|
selector: 'ds-bitstream-download-page',
|
||||||
@@ -62,6 +66,8 @@ export class BitstreamDownloadPageComponent implements OnInit {
|
|||||||
bitstream$: Observable<Bitstream>;
|
bitstream$: Observable<Bitstream>;
|
||||||
bitstreamRD$: Observable<RemoteData<Bitstream>>;
|
bitstreamRD$: Observable<RemoteData<Bitstream>>;
|
||||||
|
|
||||||
|
configService = inject(ConfigurationDataService);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
protected router: Router,
|
protected router: Router,
|
||||||
@@ -73,6 +79,7 @@ export class BitstreamDownloadPageComponent implements OnInit {
|
|||||||
public dsoNameService: DSONameService,
|
public dsoNameService: DSONameService,
|
||||||
private signpostingDataService: SignpostingDataService,
|
private signpostingDataService: SignpostingDataService,
|
||||||
private responseService: ServerResponseService,
|
private responseService: ServerResponseService,
|
||||||
|
private matomoService: MatomoService,
|
||||||
@Inject(PLATFORM_ID) protected platformId: string,
|
@Inject(PLATFORM_ID) protected platformId: string,
|
||||||
) {
|
) {
|
||||||
this.initPageLinks();
|
this.initPageLinks();
|
||||||
@@ -83,6 +90,10 @@ export class BitstreamDownloadPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
const accessToken$: Observable<string> = this.route.queryParams.pipe(
|
||||||
|
map((queryParams: Params) => queryParams?.accessToken || null),
|
||||||
|
take(1),
|
||||||
|
);
|
||||||
|
|
||||||
this.bitstreamRD$ = this.route.data.pipe(
|
this.bitstreamRD$ = this.route.data.pipe(
|
||||||
map((data) => data.bitstream));
|
map((data) => data.bitstream));
|
||||||
@@ -96,33 +107,50 @@ export class BitstreamDownloadPageComponent implements OnInit {
|
|||||||
switchMap((bitstream: Bitstream) => {
|
switchMap((bitstream: Bitstream) => {
|
||||||
const isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined);
|
const isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined);
|
||||||
const isLoggedIn$ = this.auth.isAuthenticated();
|
const isLoggedIn$ = this.auth.isAuthenticated();
|
||||||
return observableCombineLatest([isAuthorized$, isLoggedIn$, observableOf(bitstream)]);
|
const isMatomoEnabled$ = this.matomoService.isMatomoEnabled$();
|
||||||
|
return observableCombineLatest([isAuthorized$, isLoggedIn$, isMatomoEnabled$, accessToken$, observableOf(bitstream)]);
|
||||||
}),
|
}),
|
||||||
filter(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => hasValue(isAuthorized) && hasValue(isLoggedIn)),
|
filter(([isAuthorized, isLoggedIn, isMatomoEnabled, accessToken, bitstream]: [boolean, boolean, boolean, string, Bitstream]) => (hasValue(isAuthorized) && hasValue(isLoggedIn)) || hasValue(accessToken)),
|
||||||
take(1),
|
take(1),
|
||||||
switchMap(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => {
|
switchMap(([isAuthorized, isLoggedIn, isMatomoEnabled, accessToken, bitstream]: [boolean, boolean, boolean, string, Bitstream]) => {
|
||||||
if (isAuthorized && isLoggedIn) {
|
if (isAuthorized && isLoggedIn) {
|
||||||
return this.fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe(
|
return this.fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe(
|
||||||
filter((fileLink) => hasValue(fileLink)),
|
filter((fileLink) => hasValue(fileLink)),
|
||||||
take(1),
|
take(1),
|
||||||
map((fileLink) => {
|
map((fileLink) => {
|
||||||
return [isAuthorized, isLoggedIn, bitstream, fileLink];
|
return [isAuthorized, isLoggedIn, isMatomoEnabled, bitstream, fileLink];
|
||||||
}));
|
}));
|
||||||
|
} else if (hasValue(accessToken)) {
|
||||||
|
return [[isAuthorized, !isLoggedIn, isMatomoEnabled, bitstream, '', accessToken]];
|
||||||
} else {
|
} else {
|
||||||
return [[isAuthorized, isLoggedIn, bitstream, '']];
|
return [[isAuthorized, isLoggedIn, isMatomoEnabled, bitstream, bitstream._links.content.href]];
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
).subscribe(([isAuthorized, isLoggedIn, bitstream, fileLink]: [boolean, boolean, Bitstream, string]) => {
|
switchMap(([isAuthorized, isLoggedIn, isMatomoEnabled, bitstream, fileLink, accessToken]: [boolean, boolean, boolean, Bitstream, string, string]) => {
|
||||||
|
if (isMatomoEnabled) {
|
||||||
|
return this.matomoService.appendVisitorId(fileLink).pipe(
|
||||||
|
map((fileLinkWithVisitorId) => [isAuthorized, isLoggedIn, bitstream, fileLinkWithVisitorId, accessToken]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return observableOf([isAuthorized, isLoggedIn, bitstream, fileLink, accessToken]);
|
||||||
|
}),
|
||||||
|
).subscribe(([isAuthorized, isLoggedIn, bitstream, fileLink, accessToken]: [boolean, boolean, Bitstream, string, string]) => {
|
||||||
if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) {
|
if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) {
|
||||||
this.hardRedirectService.redirect(fileLink);
|
this.hardRedirectService.redirect(fileLink);
|
||||||
} else if (isAuthorized && !isLoggedIn) {
|
} else if (isAuthorized && !isLoggedIn && !hasValue(accessToken)) {
|
||||||
this.hardRedirectService.redirect(bitstream._links.content.href);
|
this.hardRedirectService.redirect(fileLink);
|
||||||
} else if (!isAuthorized && isLoggedIn) {
|
} else if (!isAuthorized) {
|
||||||
|
// Either we have an access token, or we are logged in, or we are not logged in.
|
||||||
|
// For now, the access token does not care if we are logged in or not.
|
||||||
|
if (hasValue(accessToken)) {
|
||||||
|
this.hardRedirectService.redirect(bitstream._links.content.href + '?accessToken=' + accessToken);
|
||||||
|
} else if (isLoggedIn) {
|
||||||
this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true });
|
this.router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true });
|
||||||
} else if (!isAuthorized && !isLoggedIn) {
|
} else if (!isLoggedIn) {
|
||||||
this.auth.setRedirectUrl(this.router.url);
|
this.auth.setRedirectUrl(this.router.url);
|
||||||
this.router.navigateByUrl('login');
|
this.router.navigateByUrl('login');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<h1 class="h2">{{dsoNameService.getName(bitstreamRD?.payload)}} <span class="text-muted">({{bitstreamRD?.payload?.sizeBytes | dsFileSize}})</span></h1>
|
<h1 class="h2 dont-break-out">{{dsoNameService.getName(bitstreamRD?.payload)}} <span class="text-muted">({{bitstreamRD?.payload?.sizeBytes | dsFileSize}})</span></h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -0,0 +1,11 @@
|
|||||||
|
<div class="container">
|
||||||
|
<h1>{{ 'browse.metadata.map' | translate }}</h1>
|
||||||
|
@if (isPlatformBrowser(platformId)) {
|
||||||
|
<ds-geospatial-map [facetValues]="facetValues$"
|
||||||
|
[currentScope]="this.scope$|async"
|
||||||
|
[layout]="'browse'"
|
||||||
|
style="width: 100%;">
|
||||||
|
</ds-geospatial-map>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
@@ -0,0 +1,147 @@
|
|||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
waitForAsync,
|
||||||
|
} from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { StoreModule } from '@ngrx/store';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { of as observableOf } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { buildPaginatedList } from '../../core/data/paginated-list.model';
|
||||||
|
import { PageInfo } from '../../core/shared/page-info.model';
|
||||||
|
import { SearchService } from '../../core/shared/search/search.service';
|
||||||
|
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||||
|
import { FacetValue } from '../../shared/search/models/facet-value.model';
|
||||||
|
import { FilterType } from '../../shared/search/models/filter-type.model';
|
||||||
|
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
|
||||||
|
import { SearchFilterConfig } from '../../shared/search/models/search-filter-config.model';
|
||||||
|
import { SearchServiceStub } from '../../shared/testing/search-service.stub';
|
||||||
|
import { BrowseByGeospatialDataComponent } from './browse-by-geospatial-data.component';
|
||||||
|
|
||||||
|
// create route stub
|
||||||
|
const scope = 'test scope';
|
||||||
|
const activatedRouteStub = {
|
||||||
|
queryParams: observableOf({
|
||||||
|
scope: scope,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock search filter config
|
||||||
|
const mockFilterConfig = Object.assign(new SearchFilterConfig(), {
|
||||||
|
name: 'point',
|
||||||
|
type: FilterType.text,
|
||||||
|
hasFacets: true,
|
||||||
|
isOpenByDefault: false,
|
||||||
|
pageSize: 2,
|
||||||
|
minValue: 200,
|
||||||
|
maxValue: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock facet values with and without point data
|
||||||
|
const facetValue: FacetValue = {
|
||||||
|
label: 'test',
|
||||||
|
value: 'test',
|
||||||
|
count: 20,
|
||||||
|
_links: {
|
||||||
|
self: { href: 'selectedValue-self-link2' },
|
||||||
|
search: { href: `` },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const pointFacetValue: FacetValue = {
|
||||||
|
label: 'test point',
|
||||||
|
value: 'Point ( +174.000000 -042.000000 )',
|
||||||
|
count: 20,
|
||||||
|
_links: {
|
||||||
|
self: { href: 'selectedValue-self-link' },
|
||||||
|
search: { href: `` },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const mockValues = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [facetValue]));
|
||||||
|
const mockPointValues = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [pointFacetValue]));
|
||||||
|
|
||||||
|
// Expected search options used in getFacetValuesFor call
|
||||||
|
const expectedSearchOptions: PaginatedSearchOptions = Object.assign({
|
||||||
|
'configuration': environment.geospatialMapViewer.spatialFacetDiscoveryConfiguration,
|
||||||
|
'scope': scope,
|
||||||
|
'facetLimit': 99999,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock search config service returns mock search filter config on getConfig()
|
||||||
|
const mockSearchConfigService = jasmine.createSpyObj('searchConfigurationService', {
|
||||||
|
getConfig: createSuccessfulRemoteDataObject$([mockFilterConfig]),
|
||||||
|
});
|
||||||
|
let searchService: SearchServiceStub = new SearchServiceStub();
|
||||||
|
|
||||||
|
// initialize testing environment
|
||||||
|
describe('BrowseByGeospatialDataComponent', () => {
|
||||||
|
let component: BrowseByGeospatialDataComponent;
|
||||||
|
let fixture: ComponentFixture<BrowseByGeospatialDataComponent>;
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [ TranslateModule.forRoot(), StoreModule.forRoot(), BrowseByGeospatialDataComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: SearchService, useValue: searchService },
|
||||||
|
{ provide: SearchConfigurationService, useValue: mockSearchConfigService },
|
||||||
|
{ provide: ActivatedRoute, useValue: activatedRouteStub },
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA],
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BrowseByGeospatialDataComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('component should be created successfully', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
describe('BrowseByGeospatialDataComponent component with valid facet values', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BrowseByGeospatialDataComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockPointValues);
|
||||||
|
component.scope$ = observableOf('');
|
||||||
|
component.ngOnInit();
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call searchConfigService.getConfig() after init', waitForAsync(() => {
|
||||||
|
expect(mockSearchConfigService.getConfig).toHaveBeenCalled();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should call searchService.getFacetValuesFor() with expected parameters', waitForAsync(() => {
|
||||||
|
component.getFacetValues().subscribe(() => {
|
||||||
|
expect(searchService.getFacetValuesFor).toHaveBeenCalledWith(mockFilterConfig, 1, expectedSearchOptions, null, true);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BrowseByGeospatialDataComponent component with invalid facet values (no point data)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BrowseByGeospatialDataComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockValues);
|
||||||
|
component.scope$ = observableOf('');
|
||||||
|
component.ngOnInit();
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call searchConfigService.getConfig() after init', waitForAsync(() => {
|
||||||
|
expect(mockSearchConfigService.getConfig).toHaveBeenCalled();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should call searchService.getFacetValuesFor() with expected parameters', waitForAsync(() => {
|
||||||
|
component.getFacetValues().subscribe(() => {
|
||||||
|
expect(searchService.getFacetValuesFor).toHaveBeenCalledWith(mockFilterConfig, 1, expectedSearchOptions, null, true);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
@@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
AsyncPipe,
|
||||||
|
isPlatformBrowser,
|
||||||
|
NgIf,
|
||||||
|
} from '@angular/common';
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
Inject,
|
||||||
|
OnInit,
|
||||||
|
PLATFORM_ID,
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
ActivatedRoute,
|
||||||
|
Params,
|
||||||
|
} from '@angular/router';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import {
|
||||||
|
combineLatest,
|
||||||
|
Observable,
|
||||||
|
of,
|
||||||
|
} from 'rxjs';
|
||||||
|
import {
|
||||||
|
filter,
|
||||||
|
map,
|
||||||
|
switchMap,
|
||||||
|
take,
|
||||||
|
} from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import {
|
||||||
|
getFirstCompletedRemoteData,
|
||||||
|
getFirstSucceededRemoteDataPayload,
|
||||||
|
} from '../../core/shared/operators';
|
||||||
|
import { SearchService } from '../../core/shared/search/search.service';
|
||||||
|
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
|
||||||
|
import { hasValue } from '../../shared/empty.util';
|
||||||
|
import { GeospatialMapComponent } from '../../shared/geospatial-map/geospatial-map.component';
|
||||||
|
import { FacetValues } from '../../shared/search/models/facet-values.model';
|
||||||
|
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-browse-by-geospatial-data',
|
||||||
|
templateUrl: './browse-by-geospatial-data.component.html',
|
||||||
|
styleUrls: ['./browse-by-geospatial-data.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [GeospatialMapComponent, NgIf, AsyncPipe, TranslateModule],
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Component displaying a large 'browse map', which is really a geolocation few of the 'point' facet defined
|
||||||
|
* in the geospatial discovery configuration.
|
||||||
|
* The markers are clustered by location, and each individual marker will link to a search page for that point value
|
||||||
|
* as a filter.
|
||||||
|
*
|
||||||
|
* @author Kim Shepherd
|
||||||
|
*/
|
||||||
|
export class BrowseByGeospatialDataComponent implements OnInit {
|
||||||
|
|
||||||
|
protected readonly isPlatformBrowser = isPlatformBrowser;
|
||||||
|
|
||||||
|
public facetValues$: Observable<FacetValues> = of(null);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(PLATFORM_ID) public platformId: string,
|
||||||
|
private searchConfigurationService: SearchConfigurationService,
|
||||||
|
private searchService: SearchService,
|
||||||
|
protected route: ActivatedRoute,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public scope$: Observable<string> ;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.scope$ = this.route.queryParams.pipe(
|
||||||
|
map((params: Params) => params.scope),
|
||||||
|
);
|
||||||
|
this.facetValues$ = this.getFacetValues();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get facet values for use in rendering 'browse by' geospatial map
|
||||||
|
*/
|
||||||
|
getFacetValues(): Observable<FacetValues> {
|
||||||
|
return combineLatest([this.scope$, this.searchConfigurationService.getConfig(
|
||||||
|
// If the geospatial configuration is not found, default will be returned and used
|
||||||
|
'', environment.geospatialMapViewer.spatialFacetDiscoveryConfiguration).pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
getFirstSucceededRemoteDataPayload(),
|
||||||
|
filter((searchFilterConfigs) => hasValue(searchFilterConfigs)),
|
||||||
|
take(1),
|
||||||
|
map((searchFilterConfigs) => searchFilterConfigs[0]),
|
||||||
|
filter((searchFilterConfig) => hasValue(searchFilterConfig))),
|
||||||
|
],
|
||||||
|
).pipe(
|
||||||
|
switchMap(([scope, searchFilterConfig]) => {
|
||||||
|
// Get all points in one page, if possible
|
||||||
|
searchFilterConfig.pageSize = 99999;
|
||||||
|
const searchOptions: PaginatedSearchOptions = Object.assign({
|
||||||
|
'configuration': environment.geospatialMapViewer.spatialFacetDiscoveryConfiguration,
|
||||||
|
'scope': scope,
|
||||||
|
'facetLimit': 99999,
|
||||||
|
});
|
||||||
|
return this.searchService.getFacetValuesFor(searchFilterConfig, 1, searchOptions,
|
||||||
|
null, true);
|
||||||
|
}),
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
getFirstSucceededRemoteDataPayload(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,6 +1,8 @@
|
|||||||
import { Route } from '@angular/router';
|
import { Route } from '@angular/router';
|
||||||
|
|
||||||
|
import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
|
||||||
import { browseByDSOBreadcrumbResolver } from './browse-by-dso-breadcrumb.resolver';
|
import { browseByDSOBreadcrumbResolver } from './browse-by-dso-breadcrumb.resolver';
|
||||||
|
import { BrowseByGeospatialDataComponent } from './browse-by-geospatial-data/browse-by-geospatial-data.component';
|
||||||
import { browseByGuard } from './browse-by-guard';
|
import { browseByGuard } from './browse-by-guard';
|
||||||
import { browseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.resolver';
|
import { browseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.resolver';
|
||||||
import { BrowseByPageComponent } from './browse-by-page/browse-by-page.component';
|
import { BrowseByPageComponent } from './browse-by-page/browse-by-page.component';
|
||||||
@@ -12,6 +14,12 @@ export const ROUTES: Route[] = [
|
|||||||
breadcrumb: browseByDSOBreadcrumbResolver,
|
breadcrumb: browseByDSOBreadcrumbResolver,
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
path: 'map',
|
||||||
|
component: BrowseByGeospatialDataComponent,
|
||||||
|
resolve: { breadcrumb: i18nBreadcrumbResolver },
|
||||||
|
data: { title: 'browse.map.page', breadcrumbKey: 'browse.metadata.map' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: ':id',
|
path: ':id',
|
||||||
component: BrowseByPageComponent,
|
component: BrowseByPageComponent,
|
||||||
|
@@ -8,9 +8,7 @@ import { communityBreadcrumbResolver } from '../core/breadcrumbs/community-bread
|
|||||||
import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
|
import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
|
||||||
import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component';
|
import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component';
|
||||||
import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component';
|
import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component';
|
||||||
import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
|
import { MenuRoute } from '../shared/menu/menu-route.model';
|
||||||
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
|
|
||||||
import { MenuItemType } from '../shared/menu/menu-item-type.model';
|
|
||||||
import { collectionPageResolver } from './collection-page.resolver';
|
import { collectionPageResolver } from './collection-page.resolver';
|
||||||
import { collectionPageAdministratorGuard } from './collection-page-administrator.guard';
|
import { collectionPageAdministratorGuard } from './collection-page-administrator.guard';
|
||||||
import {
|
import {
|
||||||
@@ -82,8 +80,8 @@ export const ROUTES: Route[] = [
|
|||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: ThemedCollectionPageComponent,
|
component: ThemedCollectionPageComponent,
|
||||||
resolve: {
|
data: {
|
||||||
menu: dsoEditMenuResolver,
|
menuRoute: MenuRoute.COLLECTION_PAGE,
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@@ -91,6 +89,18 @@ export const ROUTES: Route[] = [
|
|||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
component: ComcolSearchSectionComponent,
|
component: ComcolSearchSectionComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'search',
|
||||||
|
pathMatch: 'full',
|
||||||
|
component: ComcolSearchSectionComponent,
|
||||||
|
resolve: {
|
||||||
|
breadcrumb: i18nBreadcrumbResolver,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
breadcrumbKey: 'collection.search',
|
||||||
|
menuRoute: MenuRoute.COLLECTION_PAGE,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'browse/:id',
|
path: 'browse/:id',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
@@ -99,25 +109,13 @@ export const ROUTES: Route[] = [
|
|||||||
resolve: {
|
resolve: {
|
||||||
breadcrumb: browseByI18nBreadcrumbResolver,
|
breadcrumb: browseByI18nBreadcrumbResolver,
|
||||||
},
|
},
|
||||||
data: { breadcrumbKey: 'browse.metadata' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
data: {
|
data: {
|
||||||
menu: {
|
breadcrumbKey: 'browse.metadata',
|
||||||
public: [{
|
menuRoute: MenuRoute.COLLECTION_PAGE,
|
||||||
id: 'statistics_collection_:id',
|
|
||||||
active: true,
|
|
||||||
visible: true,
|
|
||||||
index: 2,
|
|
||||||
model: {
|
|
||||||
type: MenuItemType.LINK,
|
|
||||||
text: 'menu.section.statistics',
|
|
||||||
link: 'statistics/collections/:id/',
|
|
||||||
} as LinkMenuItemModel,
|
|
||||||
}],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@@ -7,9 +7,7 @@ import { communityBreadcrumbResolver } from '../core/breadcrumbs/community-bread
|
|||||||
import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
|
import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
|
||||||
import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component';
|
import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component';
|
||||||
import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component';
|
import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component';
|
||||||
import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
|
import { MenuRoute } from '../shared/menu/menu-route.model';
|
||||||
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
|
|
||||||
import { MenuItemType } from '../shared/menu/menu-item-type.model';
|
|
||||||
import { communityPageResolver } from './community-page.resolver';
|
import { communityPageResolver } from './community-page.resolver';
|
||||||
import { communityPageAdministratorGuard } from './community-page-administrator.guard';
|
import { communityPageAdministratorGuard } from './community-page-administrator.guard';
|
||||||
import {
|
import {
|
||||||
@@ -69,8 +67,8 @@ export const ROUTES: Route[] = [
|
|||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: ThemedCommunityPageComponent,
|
component: ThemedCommunityPageComponent,
|
||||||
resolve: {
|
data: {
|
||||||
menu: dsoEditMenuResolver,
|
menuRoute: MenuRoute.COMMUNITY_PAGE,
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@@ -78,6 +76,18 @@ export const ROUTES: Route[] = [
|
|||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
component: ComcolSearchSectionComponent,
|
component: ComcolSearchSectionComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'search',
|
||||||
|
pathMatch: 'full',
|
||||||
|
component: ComcolSearchSectionComponent,
|
||||||
|
resolve: {
|
||||||
|
breadcrumb: i18nBreadcrumbResolver,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
breadcrumbKey: 'community.search',
|
||||||
|
menuRoute: MenuRoute.COMMUNITY_PAGE,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'subcoms-cols',
|
path: 'subcoms-cols',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
@@ -85,7 +95,10 @@ export const ROUTES: Route[] = [
|
|||||||
resolve: {
|
resolve: {
|
||||||
breadcrumb: i18nBreadcrumbResolver,
|
breadcrumb: i18nBreadcrumbResolver,
|
||||||
},
|
},
|
||||||
data: { breadcrumbKey: 'community.subcoms-cols' },
|
data: {
|
||||||
|
breadcrumbKey: 'community.subcoms-cols',
|
||||||
|
menuRoute: MenuRoute.COMMUNITY_PAGE,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'browse/:id',
|
path: 'browse/:id',
|
||||||
@@ -95,25 +108,13 @@ export const ROUTES: Route[] = [
|
|||||||
resolve: {
|
resolve: {
|
||||||
breadcrumb: browseByI18nBreadcrumbResolver,
|
breadcrumb: browseByI18nBreadcrumbResolver,
|
||||||
},
|
},
|
||||||
data: { breadcrumbKey: 'browse.metadata' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
data: {
|
data: {
|
||||||
menu: {
|
breadcrumbKey: 'browse.metadata',
|
||||||
public: [{
|
menuRoute: MenuRoute.COMMUNITY_PAGE,
|
||||||
id: 'statistics_community_:id',
|
|
||||||
active: true,
|
|
||||||
visible: true,
|
|
||||||
index: 2,
|
|
||||||
model: {
|
|
||||||
type: MenuItemType.LINK,
|
|
||||||
text: 'menu.section.statistics',
|
|
||||||
link: 'statistics/communities/:id/',
|
|
||||||
} as LinkMenuItemModel,
|
|
||||||
}],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
62
src/app/core/auth/access-token.resolver.ts
Normal file
62
src/app/core/auth/access-token.resolver.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
ResolveFn,
|
||||||
|
Router,
|
||||||
|
} from '@angular/router';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import {
|
||||||
|
map,
|
||||||
|
tap,
|
||||||
|
} from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { getForbiddenRoute } from '../../app-routing-paths';
|
||||||
|
import { hasValue } from '../../shared/empty.util';
|
||||||
|
import { ItemRequestDataService } from '../data/item-request-data.service';
|
||||||
|
import { RemoteData } from '../data/remote-data';
|
||||||
|
import { redirectOn4xx } from '../shared/authorized.operators';
|
||||||
|
import { ItemRequest } from '../shared/item-request.model';
|
||||||
|
import {
|
||||||
|
getFirstCompletedRemoteData,
|
||||||
|
getFirstSucceededRemoteDataPayload,
|
||||||
|
} from '../shared/operators';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an ItemRequest based on the accessToken in the query params
|
||||||
|
* Used in item-page-routes.ts to resolve the item request for all Item page components
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
* @param router
|
||||||
|
* @param authService
|
||||||
|
* @param itemRequestDataService
|
||||||
|
*/
|
||||||
|
export const accessTokenResolver: ResolveFn<ItemRequest> = (
|
||||||
|
route,
|
||||||
|
state,
|
||||||
|
router: Router = inject(Router),
|
||||||
|
authService: AuthService = inject(AuthService),
|
||||||
|
itemRequestDataService: ItemRequestDataService = inject(ItemRequestDataService),
|
||||||
|
): Observable<ItemRequest> => {
|
||||||
|
const accessToken = route.queryParams.accessToken;
|
||||||
|
// Set null object if accesstoken is empty
|
||||||
|
if ( !hasValue(accessToken) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Get the item request from the server
|
||||||
|
return itemRequestDataService.getSanitizedRequestByAccessToken(accessToken).pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
// Handle authorization errors, not found errors and forbidden errors as normal
|
||||||
|
redirectOn4xx(router, authService),
|
||||||
|
map((rd: RemoteData<ItemRequest>) => rd),
|
||||||
|
// Get payload of the item request
|
||||||
|
getFirstSucceededRemoteDataPayload(),
|
||||||
|
tap(request => {
|
||||||
|
if (!hasValue(request)) {
|
||||||
|
// If the request is not found, redirect to 403 Forbidden
|
||||||
|
router.navigateByUrl(getForbiddenRoute());
|
||||||
|
}
|
||||||
|
// Return the resolved item request object
|
||||||
|
return request;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
133
src/app/core/auth/auth-methods.service.spec.ts
Normal file
133
src/app/core/auth/auth-methods.service.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import {
|
||||||
|
Store,
|
||||||
|
StoreModule,
|
||||||
|
} from '@ngrx/store';
|
||||||
|
import {
|
||||||
|
MockStore,
|
||||||
|
provideMockStore,
|
||||||
|
} from '@ngrx/store/testing';
|
||||||
|
|
||||||
|
import { storeModuleConfig } from '../../app.reducer';
|
||||||
|
import { AuthMethodTypeComponent } from '../../shared/log-in/methods/auth-methods.type';
|
||||||
|
import { authReducer } from './auth.reducer';
|
||||||
|
import { AuthMethodsService } from './auth-methods.service';
|
||||||
|
import { AuthMethod } from './models/auth.method';
|
||||||
|
import { AuthMethodType } from './models/auth.method-type';
|
||||||
|
|
||||||
|
describe('AuthMethodsService', () => {
|
||||||
|
let service: AuthMethodsService;
|
||||||
|
let store: MockStore;
|
||||||
|
let mockAuthMethods: Map<AuthMethodType, AuthMethodTypeComponent>;
|
||||||
|
let mockAuthMethodsArray: AuthMethod[] = [
|
||||||
|
{ id: 'password', authMethodType: AuthMethodType.Password, position: 2 } as AuthMethod,
|
||||||
|
{ id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 } as AuthMethod,
|
||||||
|
{ id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 } as AuthMethod,
|
||||||
|
{ id: 'ip', authMethodType: AuthMethodType.Ip, position: 4 } as AuthMethod,
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
core: {
|
||||||
|
auth: {
|
||||||
|
authMethods: mockAuthMethodsArray,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
StoreModule.forRoot(authReducer, storeModuleConfig),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AuthMethodsService,
|
||||||
|
provideMockStore({ initialState }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(AuthMethodsService);
|
||||||
|
store = TestBed.inject(Store) as MockStore;
|
||||||
|
|
||||||
|
// Setup mock auth methods map
|
||||||
|
mockAuthMethods = new Map<AuthMethodType, AuthMethodTypeComponent>();
|
||||||
|
mockAuthMethods.set(AuthMethodType.Password, {} as AuthMethodTypeComponent);
|
||||||
|
mockAuthMethods.set(AuthMethodType.Shibboleth, {} as AuthMethodTypeComponent);
|
||||||
|
mockAuthMethods.set(AuthMethodType.Oidc, {} as AuthMethodTypeComponent);
|
||||||
|
mockAuthMethods.set(AuthMethodType.Ip, {} as AuthMethodTypeComponent);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAuthMethods', () => {
|
||||||
|
it('should return auth methods sorted by position', () => {
|
||||||
|
|
||||||
|
// Expected result after sorting and filtering IP auth
|
||||||
|
const expected = [
|
||||||
|
{ id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 },
|
||||||
|
{ id: 'password', authMethodType: AuthMethodType.Password, position: 2 },
|
||||||
|
{ id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
service.getAuthMethods(mockAuthMethods).subscribe(result => {
|
||||||
|
expect(result.length).toBe(3);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude specified auth method type', () => {
|
||||||
|
|
||||||
|
// Expected result after excluding Password auth and filtering IP auth
|
||||||
|
const expected = [
|
||||||
|
{ id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 },
|
||||||
|
{ id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
service.getAuthMethods(mockAuthMethods, AuthMethodType.Password).subscribe(result => {
|
||||||
|
expect(result.length).toBe(2);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should always filter out IP authentication method', () => {
|
||||||
|
|
||||||
|
// Add IP auth to the mock methods map
|
||||||
|
mockAuthMethods.set(AuthMethodType.Ip, {} as AuthMethodTypeComponent);
|
||||||
|
|
||||||
|
|
||||||
|
service.getAuthMethods(mockAuthMethods).subscribe(result => {
|
||||||
|
expect(result.length).toBe(3);
|
||||||
|
expect(result.find(method => method.authMethodType === AuthMethodType.Ip)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty auth methods array', () => {
|
||||||
|
const authMethods = new Map<AuthMethodType, AuthMethodTypeComponent>();
|
||||||
|
|
||||||
|
|
||||||
|
service.getAuthMethods(authMethods).subscribe(result => {
|
||||||
|
expect(result.length).toBe(0);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle duplicate auth method types and keep only unique ones', () => {
|
||||||
|
// Arrange
|
||||||
|
const duplicateMethodsArray = [
|
||||||
|
...mockAuthMethodsArray,
|
||||||
|
{ id: 'password2', authMethodType: AuthMethodType.Password, position: 5 } as AuthMethod,
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
service.getAuthMethods(mockAuthMethods).subscribe(result => {
|
||||||
|
expect(result.length).toBe(3);
|
||||||
|
// Check that we only have one Password auth method
|
||||||
|
const passwordMethods = result.filter(method => method.authMethodType === AuthMethodType.Password);
|
||||||
|
expect(passwordMethods.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
51
src/app/core/auth/auth-methods.service.ts
Normal file
51
src/app/core/auth/auth-methods.service.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
select,
|
||||||
|
Store,
|
||||||
|
} from '@ngrx/store';
|
||||||
|
import uniqBy from 'lodash/uniqBy';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { AppState } from '../../app.reducer';
|
||||||
|
import { AuthMethodTypeComponent } from '../../shared/log-in/methods/auth-methods.type';
|
||||||
|
import { rendersAuthMethodType } from '../../shared/log-in/methods/log-in.methods-decorator.utils';
|
||||||
|
import { AuthMethod } from './models/auth.method';
|
||||||
|
import { AuthMethodType } from './models/auth.method-type';
|
||||||
|
import { getAuthenticationMethods } from './selectors';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Service responsible for managing and filtering authentication methods.
|
||||||
|
* Provides methods to retrieve and process authentication methods from the application store.
|
||||||
|
*/
|
||||||
|
export class AuthMethodsService {
|
||||||
|
constructor(protected store: Store<AppState>) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves and processes authentication methods from the store.
|
||||||
|
*
|
||||||
|
* @param authMethods A map of authentication method types to their corresponding components
|
||||||
|
* @param excludedAuthMethod Optional authentication method type to exclude from the results
|
||||||
|
* @returns An Observable of filtered and sorted authentication methods
|
||||||
|
*/
|
||||||
|
public getAuthMethods(
|
||||||
|
authMethods: Map<AuthMethodType, AuthMethodTypeComponent>,
|
||||||
|
excludedAuthMethod?: AuthMethodType,
|
||||||
|
): Observable<AuthMethod[]> {
|
||||||
|
return this.store.pipe(
|
||||||
|
select(getAuthenticationMethods),
|
||||||
|
map((methods: AuthMethod[]) => methods
|
||||||
|
// ignore the given auth method if it should be excluded
|
||||||
|
.filter((authMethod: AuthMethod) => excludedAuthMethod == null || authMethod.authMethodType !== excludedAuthMethod)
|
||||||
|
.filter((authMethod: AuthMethod) => rendersAuthMethodType(authMethods, authMethod.authMethodType) !== undefined)
|
||||||
|
.sort((method1: AuthMethod, method2: AuthMethod) => method1.position - method2.position),
|
||||||
|
),
|
||||||
|
// ignore the ip authentication method when it's returned by the backend
|
||||||
|
map((methods: AuthMethod[]) => uniqBy(methods.filter(a => a.authMethodType !== AuthMethodType.Ip), 'authMethodType')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -139,4 +139,5 @@ export abstract class AuthRequestService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -288,7 +288,7 @@ describe('AuthService test', () => {
|
|||||||
(state as any).core = Object.create({});
|
(state as any).core = Object.create({});
|
||||||
(state as any).core.auth = authenticatedState;
|
(state as any).core.auth = authenticatedState;
|
||||||
});
|
});
|
||||||
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
|
authService = new AuthService(window, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should return true when user is logged in', () => {
|
it('should return true when user is logged in', () => {
|
||||||
@@ -373,7 +373,7 @@ describe('AuthService test', () => {
|
|||||||
(state as any).core = Object.create({});
|
(state as any).core = Object.create({});
|
||||||
(state as any).core.auth = authenticatedState;
|
(state as any).core.auth = authenticatedState;
|
||||||
});
|
});
|
||||||
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
|
authService = new AuthService(window, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
|
||||||
storage = (authService as any).storage;
|
storage = (authService as any).storage;
|
||||||
routeServiceMock = TestBed.inject(RouteService);
|
routeServiceMock = TestBed.inject(RouteService);
|
||||||
routerStub = TestBed.inject(Router);
|
routerStub = TestBed.inject(Router);
|
||||||
@@ -593,7 +593,7 @@ describe('AuthService test', () => {
|
|||||||
(state as any).core = Object.create({});
|
(state as any).core = Object.create({});
|
||||||
(state as any).core.auth = unAuthenticatedState;
|
(state as any).core.auth = unAuthenticatedState;
|
||||||
});
|
});
|
||||||
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
|
authService = new AuthService(window, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should return null for the shortlived token', () => {
|
it('should return null for the shortlived token', () => {
|
||||||
@@ -633,7 +633,7 @@ describe('AuthService test', () => {
|
|||||||
(state as any).core = Object.create({});
|
(state as any).core = Object.create({});
|
||||||
(state as any).core.auth = idleState;
|
(state as any).core.auth = idleState;
|
||||||
});
|
});
|
||||||
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
|
authService = new AuthService(window, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('isUserIdle should return true when user is not idle', () => {
|
it('isUserIdle should return true when user is not idle', () => {
|
||||||
|
@@ -2,7 +2,6 @@ import { HttpHeaders } from '@angular/common/http';
|
|||||||
import {
|
import {
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Optional,
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
@@ -24,10 +23,6 @@ import {
|
|||||||
} from 'rxjs/operators';
|
} from 'rxjs/operators';
|
||||||
|
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import {
|
|
||||||
REQUEST,
|
|
||||||
RESPONSE,
|
|
||||||
} from '../../../express.tokens';
|
|
||||||
import { AppState } from '../../app.reducer';
|
import { AppState } from '../../app.reducer';
|
||||||
import {
|
import {
|
||||||
hasNoValue,
|
hasNoValue,
|
||||||
@@ -62,6 +57,7 @@ import {
|
|||||||
getFirstCompletedRemoteData,
|
getFirstCompletedRemoteData,
|
||||||
} from '../shared/operators';
|
} from '../shared/operators';
|
||||||
import { PageInfo } from '../shared/page-info.model';
|
import { PageInfo } from '../shared/page-info.model';
|
||||||
|
import { URLCombiner } from '../url-combiner/url-combiner';
|
||||||
import {
|
import {
|
||||||
CheckAuthenticationTokenAction,
|
CheckAuthenticationTokenAction,
|
||||||
RefreshTokenAction,
|
RefreshTokenAction,
|
||||||
@@ -111,9 +107,8 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
private tokenRefreshTimer;
|
private tokenRefreshTimer;
|
||||||
|
|
||||||
constructor(@Inject(REQUEST) protected req: any,
|
constructor(
|
||||||
@Inject(NativeWindowService) protected _window: NativeWindowRef,
|
@Inject(NativeWindowService) protected _window: NativeWindowRef,
|
||||||
@Optional() @Inject(RESPONSE) private response: any,
|
|
||||||
protected authRequestService: AuthRequestService,
|
protected authRequestService: AuthRequestService,
|
||||||
protected epersonService: EPersonDataService,
|
protected epersonService: EPersonDataService,
|
||||||
protected router: Router,
|
protected router: Router,
|
||||||
@@ -121,8 +116,8 @@ export class AuthService {
|
|||||||
protected storage: CookieService,
|
protected storage: CookieService,
|
||||||
protected store: Store<AppState>,
|
protected store: Store<AppState>,
|
||||||
protected hardRedirectService: HardRedirectService,
|
protected hardRedirectService: HardRedirectService,
|
||||||
private notificationService: NotificationsService,
|
protected notificationService: NotificationsService,
|
||||||
private translateService: TranslateService,
|
protected translateService: TranslateService,
|
||||||
) {
|
) {
|
||||||
this.store.pipe(
|
this.store.pipe(
|
||||||
// when this service is constructed the store is not fully initialized yet
|
// when this service is constructed the store is not fully initialized yet
|
||||||
@@ -504,10 +499,6 @@ export class AuthService {
|
|||||||
if (this._window.nativeWindow.location) {
|
if (this._window.nativeWindow.location) {
|
||||||
// Hard redirect to login page, so that all state is definitely lost
|
// Hard redirect to login page, so that all state is definitely lost
|
||||||
this._window.nativeWindow.location.href = redirectUrl;
|
this._window.nativeWindow.location.href = redirectUrl;
|
||||||
} else if (this.response) {
|
|
||||||
if (!this.response._headerSent) {
|
|
||||||
this.response.redirect(302, redirectUrl);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.router.navigateByUrl(redirectUrl);
|
this.router.navigateByUrl(redirectUrl);
|
||||||
}
|
}
|
||||||
@@ -579,6 +570,31 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the external server redirect URL.
|
||||||
|
* @param origin - The origin route.
|
||||||
|
* @param redirectRoute - The redirect route.
|
||||||
|
* @param location - The location.
|
||||||
|
* @returns The external server redirect URL.
|
||||||
|
*/
|
||||||
|
getExternalServerRedirectUrl(origin: string, redirectRoute: string, location: string): string {
|
||||||
|
const correctRedirectUrl = new URLCombiner(origin, redirectRoute).toString();
|
||||||
|
|
||||||
|
let externalServerUrl = location;
|
||||||
|
const myRegexp = /\?redirectUrl=(.*)/g;
|
||||||
|
const match = myRegexp.exec(location);
|
||||||
|
const redirectUrlFromServer = (match && match[1]) ? match[1] : null;
|
||||||
|
|
||||||
|
// Check whether the current page is different from the redirect url received from rest
|
||||||
|
if (isNotNull(redirectUrlFromServer) && redirectUrlFromServer !== correctRedirectUrl) {
|
||||||
|
// change the redirect url with the current page url
|
||||||
|
const newRedirectUrl = `?redirectUrl=${correctRedirectUrl}`;
|
||||||
|
externalServerUrl = location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return externalServerUrl;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear redirect url
|
* Clear redirect url
|
||||||
*/
|
*/
|
||||||
@@ -663,5 +679,4 @@ export class AuthService {
|
|||||||
this.store.dispatch(new UnsetUserAsIdleAction());
|
this.store.dispatch(new UnsetUserAsIdleAction());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -6,5 +6,5 @@ export enum AuthMethodType {
|
|||||||
X509 = 'x509',
|
X509 = 'x509',
|
||||||
Oidc = 'oidc',
|
Oidc = 'oidc',
|
||||||
Orcid = 'orcid',
|
Orcid = 'orcid',
|
||||||
Saml = 'saml'
|
Saml = 'saml',
|
||||||
}
|
}
|
||||||
|
4
src/app/core/auth/models/auth.registration-type.ts
Normal file
4
src/app/core/auth/models/auth.registration-type.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export enum AuthRegistrationType {
|
||||||
|
Orcid = 'ORCID',
|
||||||
|
Validation = 'VALIDATION_',
|
||||||
|
}
|
@@ -1,15 +1,40 @@
|
|||||||
import { HttpHeaders } from '@angular/common/http';
|
import { HttpHeaders } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Optional,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import {
|
||||||
|
REQUEST,
|
||||||
|
RESPONSE,
|
||||||
|
} from '../../../express.tokens';
|
||||||
|
import { AppState } from '../../app.reducer';
|
||||||
import {
|
import {
|
||||||
hasValue,
|
hasValue,
|
||||||
isNotEmpty,
|
isNotEmpty,
|
||||||
} from '../../shared/empty.util';
|
} from '../../shared/empty.util';
|
||||||
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
import { RemoteData } from '../data/remote-data';
|
import { RemoteData } from '../data/remote-data';
|
||||||
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
||||||
import { AuthService } from './auth.service';
|
import { EPersonDataService } from '../eperson/eperson-data.service';
|
||||||
|
import { CookieService } from '../services/cookie.service';
|
||||||
|
import { HardRedirectService } from '../services/hard-redirect.service';
|
||||||
|
import { RouteService } from '../services/route.service';
|
||||||
|
import {
|
||||||
|
NativeWindowRef,
|
||||||
|
NativeWindowService,
|
||||||
|
} from '../services/window.service';
|
||||||
|
import {
|
||||||
|
AuthService,
|
||||||
|
LOGIN_ROUTE,
|
||||||
|
} from './auth.service';
|
||||||
|
import { AuthRequestService } from './auth-request.service';
|
||||||
import { AuthStatus } from './models/auth-status.model';
|
import { AuthStatus } from './models/auth-status.model';
|
||||||
import { AuthTokenInfo } from './models/auth-token-info.model';
|
import { AuthTokenInfo } from './models/auth-token-info.model';
|
||||||
|
|
||||||
@@ -19,6 +44,34 @@ import { AuthTokenInfo } from './models/auth-token-info.model';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ServerAuthService extends AuthService {
|
export class ServerAuthService extends AuthService {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(REQUEST) protected req: any,
|
||||||
|
@Optional() @Inject(RESPONSE) private response: any,
|
||||||
|
@Inject(NativeWindowService) protected _window: NativeWindowRef,
|
||||||
|
protected authRequestService: AuthRequestService,
|
||||||
|
protected epersonService: EPersonDataService,
|
||||||
|
protected router: Router,
|
||||||
|
protected routeService: RouteService,
|
||||||
|
protected storage: CookieService,
|
||||||
|
protected store: Store<AppState>,
|
||||||
|
protected hardRedirectService: HardRedirectService,
|
||||||
|
protected notificationService: NotificationsService,
|
||||||
|
protected translateService: TranslateService,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
_window,
|
||||||
|
authRequestService,
|
||||||
|
epersonService,
|
||||||
|
router,
|
||||||
|
routeService,
|
||||||
|
storage,
|
||||||
|
store,
|
||||||
|
hardRedirectService,
|
||||||
|
notificationService,
|
||||||
|
translateService,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the authenticated user
|
* Returns the authenticated user
|
||||||
* @returns {User}
|
* @returns {User}
|
||||||
@@ -62,4 +115,18 @@ export class ServerAuthService extends AuthService {
|
|||||||
map((rd: RemoteData<AuthStatus>) => Object.assign(new AuthStatus(), rd.payload)),
|
map((rd: RemoteData<AuthStatus>) => Object.assign(new AuthStatus(), rd.payload)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override redirectToLoginWhenTokenExpired() {
|
||||||
|
const redirectUrl = LOGIN_ROUTE + '?expired=true';
|
||||||
|
if (this._window.nativeWindow.location) {
|
||||||
|
// Hard redirect to login page, so that all state is definitely lost
|
||||||
|
this._window.nativeWindow.location.href = redirectUrl;
|
||||||
|
} else if (this.response) {
|
||||||
|
if (!this.response._headerSent) {
|
||||||
|
this.response.redirect(302, redirectUrl);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.router.navigateByUrl(redirectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,43 +0,0 @@
|
|||||||
import {
|
|
||||||
TestBed,
|
|
||||||
waitForAsync,
|
|
||||||
} from '@angular/core/testing';
|
|
||||||
import { getTestScheduler } from 'jasmine-marbles';
|
|
||||||
|
|
||||||
import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
|
|
||||||
import { QualityAssuranceBreadcrumbService } from './quality-assurance-breadcrumb.service';
|
|
||||||
|
|
||||||
describe('QualityAssuranceBreadcrumbService', () => {
|
|
||||||
let service: QualityAssuranceBreadcrumbService;
|
|
||||||
let translateService: any = {
|
|
||||||
instant: (str) => str,
|
|
||||||
};
|
|
||||||
|
|
||||||
let exampleString;
|
|
||||||
let exampleURL;
|
|
||||||
let exampleQaKey;
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
exampleString = 'sourceId';
|
|
||||||
exampleURL = '/test/quality-assurance/';
|
|
||||||
exampleQaKey = 'admin.quality-assurance.breadcrumbs';
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
|
||||||
init();
|
|
||||||
TestBed.configureTestingModule({}).compileComponents();
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service = new QualityAssuranceBreadcrumbService(translateService);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getBreadcrumbs', () => {
|
|
||||||
it('should return a breadcrumb based on a string', () => {
|
|
||||||
const breadcrumbs = service.getBreadcrumbs(exampleString, exampleURL);
|
|
||||||
getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(exampleQaKey, exampleURL),
|
|
||||||
new Breadcrumb(exampleString, exampleURL + exampleString)],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@@ -1,15 +1,17 @@
|
|||||||
import { qualityAssuranceBreadcrumbResolver } from './quality-assurance-breadcrumb.resolver';
|
import { sourcesBreadcrumbResolver } from './sources-breadcrumb.resolver';
|
||||||
|
|
||||||
describe('qualityAssuranceBreadcrumbResolver', () => {
|
describe('sourcesBreadcrumbResolver', () => {
|
||||||
describe('resolve', () => {
|
describe('resolve', () => {
|
||||||
let resolver: any;
|
let resolver: any;
|
||||||
let qualityAssuranceBreadcrumbService: any;
|
let sourcesBreadcrumbService: any;
|
||||||
let route: any;
|
let route: any;
|
||||||
|
const i18nKey = 'breadcrumbKey';
|
||||||
const fullPath = '/test/quality-assurance/';
|
const fullPath = '/test/quality-assurance/';
|
||||||
const expectedKey = 'testSourceId:testTopicId';
|
const expectedKey = 'breadcrumbKey:testSourceId:testTopicId';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
route = {
|
route = {
|
||||||
|
data: { breadcrumbKey: i18nKey },
|
||||||
paramMap: {
|
paramMap: {
|
||||||
get: function (param) {
|
get: function (param) {
|
||||||
return this[param];
|
return this[param];
|
||||||
@@ -18,13 +20,13 @@ describe('qualityAssuranceBreadcrumbResolver', () => {
|
|||||||
topicId: 'testTopicId',
|
topicId: 'testTopicId',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
qualityAssuranceBreadcrumbService = {};
|
sourcesBreadcrumbService = {};
|
||||||
resolver = qualityAssuranceBreadcrumbResolver;
|
resolver = sourcesBreadcrumbResolver;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should resolve the breadcrumb config', () => {
|
it('should resolve the breadcrumb config', () => {
|
||||||
const resolvedConfig = resolver(route as any, { url: fullPath + 'testSourceId' } as any, qualityAssuranceBreadcrumbService);
|
const resolvedConfig = resolver(route as any, { url: fullPath + 'testSourceId' } as any, sourcesBreadcrumbService);
|
||||||
const expectedConfig = { provider: qualityAssuranceBreadcrumbService, key: expectedKey, url: fullPath };
|
const expectedConfig = { provider: sourcesBreadcrumbService, key: expectedKey, url: fullPath };
|
||||||
expect(resolvedConfig).toEqual(expectedConfig);
|
expect(resolvedConfig).toEqual(expectedConfig);
|
||||||
});
|
});
|
||||||
});
|
});
|
@@ -6,16 +6,17 @@ import {
|
|||||||
} from '@angular/router';
|
} from '@angular/router';
|
||||||
|
|
||||||
import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model';
|
import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model';
|
||||||
import { QualityAssuranceBreadcrumbService } from './quality-assurance-breadcrumb.service';
|
import { SourcesBreadcrumbService } from './sources-breadcrumb.service';
|
||||||
|
|
||||||
export const qualityAssuranceBreadcrumbResolver: ResolveFn<BreadcrumbConfig<string>> = (
|
export const sourcesBreadcrumbResolver: ResolveFn<BreadcrumbConfig<string>> = (
|
||||||
route: ActivatedRouteSnapshot,
|
route: ActivatedRouteSnapshot,
|
||||||
state: RouterStateSnapshot,
|
state: RouterStateSnapshot,
|
||||||
breadcrumbService: QualityAssuranceBreadcrumbService = inject(QualityAssuranceBreadcrumbService),
|
breadcrumbService: SourcesBreadcrumbService = inject(SourcesBreadcrumbService),
|
||||||
): BreadcrumbConfig<string> => {
|
): BreadcrumbConfig<string> => {
|
||||||
|
const breadcrumbKey = route.data.breadcrumbKey;
|
||||||
const sourceId = route.paramMap.get('sourceId');
|
const sourceId = route.paramMap.get('sourceId');
|
||||||
const topicId = route.paramMap.get('topicId');
|
const topicId = route.paramMap.get('topicId');
|
||||||
let key = sourceId;
|
let key = `${breadcrumbKey}:${sourceId}`;
|
||||||
|
|
||||||
if (topicId) {
|
if (topicId) {
|
||||||
key += `:${topicId}`;
|
key += `:${topicId}`;
|
60
src/app/core/breadcrumbs/sources-breadcrumb.service.spec.ts
Normal file
60
src/app/core/breadcrumbs/sources-breadcrumb.service.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
TestBed,
|
||||||
|
waitForAsync,
|
||||||
|
} from '@angular/core/testing';
|
||||||
|
import { getTestScheduler } from 'jasmine-marbles';
|
||||||
|
|
||||||
|
import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
|
||||||
|
import { SourcesBreadcrumbService } from './sources-breadcrumb.service';
|
||||||
|
|
||||||
|
describe('SourcesBreadcrumbService', () => {
|
||||||
|
let service: SourcesBreadcrumbService;
|
||||||
|
let translateService: any = {
|
||||||
|
instant: (str) => str,
|
||||||
|
};
|
||||||
|
|
||||||
|
let exampleString;
|
||||||
|
let exampleSource;
|
||||||
|
let exampleTopic;
|
||||||
|
let exampleArg;
|
||||||
|
let exampleArgTopic;
|
||||||
|
let exampleURL;
|
||||||
|
let exampleQaKey;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
exampleString = 'admin.quality-assurance';
|
||||||
|
exampleSource = 'sourceId';
|
||||||
|
exampleTopic = 'topic';
|
||||||
|
exampleArg = `${exampleString}:${exampleSource}`;
|
||||||
|
exampleArgTopic = `${exampleString}:${exampleSource}:${exampleTopic}`;
|
||||||
|
exampleURL = '/test/quality-assurance/';
|
||||||
|
exampleQaKey = 'admin.quality-assurance.breadcrumbs';
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
init();
|
||||||
|
TestBed.configureTestingModule({}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new SourcesBreadcrumbService(translateService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBreadcrumbs', () => {
|
||||||
|
|
||||||
|
it('should return a breadcrumb based on source only', () => {
|
||||||
|
const breadcrumbs = service.getBreadcrumbs(exampleArg, exampleURL);
|
||||||
|
getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(exampleQaKey, exampleURL),
|
||||||
|
new Breadcrumb(exampleSource, exampleURL + exampleSource)],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a breadcrumb based also on topic', () => {
|
||||||
|
const breadcrumbs = service.getBreadcrumbs(exampleArgTopic, exampleURL);
|
||||||
|
getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(exampleQaKey, exampleURL),
|
||||||
|
new Breadcrumb(exampleSource, exampleURL + exampleSource),
|
||||||
|
new Breadcrumb(exampleTopic, undefined)],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -14,9 +14,9 @@ import { BreadcrumbsProviderService } from './breadcrumbsProviderService';
|
|||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class QualityAssuranceBreadcrumbService implements BreadcrumbsProviderService<string> {
|
export class SourcesBreadcrumbService implements BreadcrumbsProviderService<string> {
|
||||||
|
|
||||||
private QUALITY_ASSURANCE_BREADCRUMB_KEY = 'admin.quality-assurance.breadcrumbs';
|
private BREADCRUMB_SUFFIX = '.breadcrumbs';
|
||||||
constructor(
|
constructor(
|
||||||
private translationService: TranslateService,
|
private translationService: TranslateService,
|
||||||
) {
|
) {
|
||||||
@@ -31,15 +31,16 @@ export class QualityAssuranceBreadcrumbService implements BreadcrumbsProviderSer
|
|||||||
*/
|
*/
|
||||||
getBreadcrumbs(key: string, url: string): Observable<Breadcrumb[]> {
|
getBreadcrumbs(key: string, url: string): Observable<Breadcrumb[]> {
|
||||||
const args = key.split(':');
|
const args = key.split(':');
|
||||||
const sourceId = args[0];
|
const breadcrumbKey = args[0] + this.BREADCRUMB_SUFFIX;
|
||||||
const topicId = args.length > 2 ? args[args.length - 1] : args[1];
|
const sourceId = args[1];
|
||||||
|
const topicId = args.length > 3 ? args[args.length - 1] : args[2];
|
||||||
|
|
||||||
if (topicId) {
|
if (topicId) {
|
||||||
return observableOf( [new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url),
|
return observableOf( [new Breadcrumb(this.translationService.instant(breadcrumbKey), url),
|
||||||
new Breadcrumb(sourceId, `${url}${sourceId}`),
|
new Breadcrumb(sourceId, `${url}${sourceId}`),
|
||||||
new Breadcrumb(topicId, undefined)]);
|
new Breadcrumb(topicId, undefined)]);
|
||||||
} else {
|
} else {
|
||||||
return observableOf([new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url),
|
return observableOf([new Breadcrumb(this.translationService.instant(breadcrumbKey), url),
|
||||||
new Breadcrumb(sourceId, `${url}${sourceId}`)]);
|
new Breadcrumb(sourceId, `${url}${sourceId}`)]);
|
||||||
}
|
}
|
||||||
|
|
@@ -46,11 +46,11 @@ describe('AccessStatusDataService', () => {
|
|||||||
createService();
|
createService();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when calling findAccessStatusFor', () => {
|
describe('when calling findItemAccessStatusFor', () => {
|
||||||
let contentSource$;
|
let contentSource$;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
contentSource$ = service.findAccessStatusFor(mockItem);
|
contentSource$ = service.findItemAccessStatusFor(mockItem);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should send a new GetRequest', fakeAsync(() => {
|
it('should send a new GetRequest', fakeAsync(() => {
|
||||||
|
@@ -29,7 +29,7 @@ export class AccessStatusDataService extends BaseDataService<AccessStatusObject>
|
|||||||
* Returns {@link RemoteData} of {@link AccessStatusObject} that is the access status of the given item
|
* Returns {@link RemoteData} of {@link AccessStatusObject} that is the access status of the given item
|
||||||
* @param item Item we want the access status of
|
* @param item Item we want the access status of
|
||||||
*/
|
*/
|
||||||
findAccessStatusFor(item: Item): Observable<RemoteData<AccessStatusObject>> {
|
findItemAccessStatusFor(item: Item): Observable<RemoteData<AccessStatusObject>> {
|
||||||
return this.findByHref(item._links.accessStatus.href);
|
return this.findByHref(item._links.accessStatus.href);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -95,7 +95,7 @@ describe('EpersonRegistrationService', () => {
|
|||||||
const expected = service.registerEmail('test@mail.org', 'afreshcaptchatoken');
|
const expected = service.registerEmail('test@mail.org', 'afreshcaptchatoken');
|
||||||
let headers = new HttpHeaders();
|
let headers = new HttpHeaders();
|
||||||
const options: HttpOptions = Object.create({});
|
const options: HttpOptions = Object.create({});
|
||||||
headers = headers.append('x-recaptcha-token', 'afreshcaptchatoken');
|
headers = headers.append('x-captcha-payload', 'afreshcaptchatoken');
|
||||||
options.headers = headers;
|
options.headers = headers;
|
||||||
|
|
||||||
expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options));
|
expect(requestService.send).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration, options));
|
||||||
@@ -105,7 +105,7 @@ describe('EpersonRegistrationService', () => {
|
|||||||
|
|
||||||
describe('searchByToken', () => {
|
describe('searchByToken', () => {
|
||||||
it('should return a registration corresponding to the provided token', () => {
|
it('should return a registration corresponding to the provided token', () => {
|
||||||
const expected = service.searchByToken('test-token');
|
const expected = service.searchByTokenAndUpdateData('test-token');
|
||||||
|
|
||||||
expect(expected).toBeObservable(cold('(a|)', {
|
expect(expected).toBeObservable(cold('(a|)', {
|
||||||
a: jasmine.objectContaining({
|
a: jasmine.objectContaining({
|
||||||
@@ -123,7 +123,7 @@ describe('EpersonRegistrationService', () => {
|
|||||||
testScheduler.run(({ cold, expectObservable }) => {
|
testScheduler.run(({ cold, expectObservable }) => {
|
||||||
rdbService.buildSingle.and.returnValue(cold('a', { a: rd }));
|
rdbService.buildSingle.and.returnValue(cold('a', { a: rd }));
|
||||||
|
|
||||||
service.searchByToken('test-token');
|
service.searchByTokenAndUpdateData('test-token');
|
||||||
|
|
||||||
expect(requestService.send).toHaveBeenCalledWith(
|
expect(requestService.send).toHaveBeenCalledWith(
|
||||||
jasmine.objectContaining({
|
jasmine.objectContaining({
|
||||||
|
@@ -3,6 +3,7 @@ import {
|
|||||||
HttpParams,
|
HttpParams,
|
||||||
} from '@angular/common/http';
|
} from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Operation } from 'fast-json-patch';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
filter,
|
filter,
|
||||||
@@ -18,6 +19,7 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv
|
|||||||
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
||||||
import { GenericConstructor } from '../shared/generic-constructor';
|
import { GenericConstructor } from '../shared/generic-constructor';
|
||||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
|
import { NoContent } from '../shared/NoContent.model';
|
||||||
import { getFirstCompletedRemoteData } from '../shared/operators';
|
import { getFirstCompletedRemoteData } from '../shared/operators';
|
||||||
import { Registration } from '../shared/registration.model';
|
import { Registration } from '../shared/registration.model';
|
||||||
import { ResponseParsingService } from './parsing.service';
|
import { ResponseParsingService } from './parsing.service';
|
||||||
@@ -25,6 +27,7 @@ import { RegistrationResponseParsingService } from './registration-response-pars
|
|||||||
import { RemoteData } from './remote-data';
|
import { RemoteData } from './remote-data';
|
||||||
import {
|
import {
|
||||||
GetRequest,
|
GetRequest,
|
||||||
|
PatchRequest,
|
||||||
PostRequest,
|
PostRequest,
|
||||||
} from './request.models';
|
} from './request.models';
|
||||||
import { RequestService } from './request.service';
|
import { RequestService } from './request.service';
|
||||||
@@ -45,7 +48,6 @@ export class EpersonRegistrationService {
|
|||||||
protected rdbService: RemoteDataBuildService,
|
protected rdbService: RemoteDataBuildService,
|
||||||
protected halService: HALEndpointService,
|
protected halService: HALEndpointService,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,7 +69,7 @@ export class EpersonRegistrationService {
|
|||||||
/**
|
/**
|
||||||
* Register a new email address
|
* Register a new email address
|
||||||
* @param email
|
* @param email
|
||||||
* @param captchaToken the value of x-recaptcha-token header
|
* @param captchaToken the value of x-captcha-payload header
|
||||||
*/
|
*/
|
||||||
registerEmail(email: string, captchaToken: string = null, type?: string): Observable<RemoteData<Registration>> {
|
registerEmail(email: string, captchaToken: string = null, type?: string): Observable<RemoteData<Registration>> {
|
||||||
const registration = new Registration();
|
const registration = new Registration();
|
||||||
@@ -80,7 +82,7 @@ export class EpersonRegistrationService {
|
|||||||
const options: HttpOptions = Object.create({});
|
const options: HttpOptions = Object.create({});
|
||||||
let headers = new HttpHeaders();
|
let headers = new HttpHeaders();
|
||||||
if (captchaToken) {
|
if (captchaToken) {
|
||||||
headers = headers.append('x-recaptcha-token', captchaToken);
|
headers = headers.append('x-captcha-payload', captchaToken);
|
||||||
}
|
}
|
||||||
options.headers = headers;
|
options.headers = headers;
|
||||||
|
|
||||||
@@ -103,10 +105,11 @@ export class EpersonRegistrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search a registration based on the provided token
|
* Searches for a registration based on the provided token.
|
||||||
* @param token
|
* @param token The token to search for.
|
||||||
|
* @returns An observable of remote data containing the registration.
|
||||||
*/
|
*/
|
||||||
searchByToken(token: string): Observable<RemoteData<Registration>> {
|
searchByTokenAndUpdateData(token: string): Observable<RemoteData<Registration>> {
|
||||||
const requestId = this.requestService.generateRequestId();
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
|
||||||
const href$ = this.getTokenSearchEndpoint(token).pipe(
|
const href$ = this.getTokenSearchEndpoint(token).pipe(
|
||||||
@@ -126,7 +129,11 @@ export class EpersonRegistrationService {
|
|||||||
return this.rdbService.buildSingle<Registration>(href$).pipe(
|
return this.rdbService.buildSingle<Registration>(href$).pipe(
|
||||||
map((rd) => {
|
map((rd) => {
|
||||||
if (rd.hasSucceeded && hasValue(rd.payload)) {
|
if (rd.hasSucceeded && hasValue(rd.payload)) {
|
||||||
return Object.assign(rd, { payload: Object.assign(rd.payload, { token }) });
|
return Object.assign(rd, { payload: Object.assign(new Registration(), {
|
||||||
|
email: rd.payload.email,
|
||||||
|
token: token,
|
||||||
|
user: rd.payload.user,
|
||||||
|
}) });
|
||||||
} else {
|
} else {
|
||||||
return rd;
|
return rd;
|
||||||
}
|
}
|
||||||
@@ -134,4 +141,69 @@ export class EpersonRegistrationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for a registration by token and handles any errors that may occur.
|
||||||
|
* @param token The token to search for.
|
||||||
|
* @returns An observable of remote data containing the registration.
|
||||||
|
*/
|
||||||
|
searchByTokenAndHandleError(token: string): Observable<RemoteData<Registration>> {
|
||||||
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
|
||||||
|
const href$ = this.getTokenSearchEndpoint(token).pipe(
|
||||||
|
find((href: string) => hasValue(href)),
|
||||||
|
);
|
||||||
|
|
||||||
|
href$.subscribe((href: string) => {
|
||||||
|
const request = new GetRequest(requestId, href);
|
||||||
|
Object.assign(request, {
|
||||||
|
getResponseParser(): GenericConstructor<ResponseParsingService> {
|
||||||
|
return RegistrationResponseParsingService;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.requestService.send(request, true);
|
||||||
|
});
|
||||||
|
return this.rdbService.buildSingle<Registration>(href$);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch the registration object to update the email address
|
||||||
|
* @param value provided by the user during the registration confirmation process
|
||||||
|
* @param registrationId The id of the registration object
|
||||||
|
* @param token The token of the registration object
|
||||||
|
* @param updateValue Flag to indicate if the email should be updated or added
|
||||||
|
* @returns Remote Data state of the patch request
|
||||||
|
*/
|
||||||
|
patchUpdateRegistration(values: string[], field: string, registrationId: string, token: string, operator: 'add' | 'replace'): Observable<RemoteData<NoContent>> {
|
||||||
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
|
||||||
|
const href$ = this.getRegistrationEndpoint().pipe(
|
||||||
|
find((href: string) => hasValue(href)),
|
||||||
|
map((href: string) => `${href}/${registrationId}?token=${token}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
href$.subscribe((href: string) => {
|
||||||
|
const operations = this.generateOperations(values, field, operator);
|
||||||
|
const patchRequest = new PatchRequest(requestId, href, operations);
|
||||||
|
this.requestService.send(patchRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.rdbService.buildFromRequestUUID(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom method to generate the operations to be performed on the registration object
|
||||||
|
* @param value provided by the user during the registration confirmation process
|
||||||
|
* @param updateValue Flag to indicate if the email should be updated or added
|
||||||
|
* @returns Operations to be performed on the registration object
|
||||||
|
*/
|
||||||
|
private generateOperations(values: string[], field: string, operator: 'add' | 'replace'): Operation[] {
|
||||||
|
let operations = [];
|
||||||
|
if (values.length > 0 && hasValue(field) ) {
|
||||||
|
operations = [{
|
||||||
|
op: operator, path: `/${field}`, value: values,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return operations;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,10 +1,17 @@
|
|||||||
|
import { HttpHeaders } from '@angular/common/http';
|
||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
|
|
||||||
import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model';
|
import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model';
|
||||||
|
import { MockBitstream1 } from '../../shared/mocks/item.mock';
|
||||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||||
|
import { ConfigurationProperty } from '../shared/configuration-property.model';
|
||||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
import { ItemRequest } from '../shared/item-request.model';
|
import { ItemRequest } from '../shared/item-request.model';
|
||||||
|
import { ConfigurationDataService } from './configuration-data.service';
|
||||||
|
import { AuthorizationDataService } from './feature-authorization/authorization-data.service';
|
||||||
|
import { FeatureID } from './feature-authorization/feature-id';
|
||||||
|
import { FindListOptions } from './find-list-options.model';
|
||||||
import { ItemRequestDataService } from './item-request-data.service';
|
import { ItemRequestDataService } from './item-request-data.service';
|
||||||
import { PostRequest } from './request.models';
|
import { PostRequest } from './request.models';
|
||||||
import { RequestService } from './request.service';
|
import { RequestService } from './request.service';
|
||||||
@@ -16,12 +23,36 @@ describe('ItemRequestDataService', () => {
|
|||||||
let requestService: RequestService;
|
let requestService: RequestService;
|
||||||
let rdbService: RemoteDataBuildService;
|
let rdbService: RemoteDataBuildService;
|
||||||
let halService: HALEndpointService;
|
let halService: HALEndpointService;
|
||||||
|
let configService: ConfigurationDataService;
|
||||||
|
let authorizationDataService: AuthorizationDataService;
|
||||||
|
|
||||||
const restApiEndpoint = 'rest/api/endpoint/';
|
const restApiEndpoint = 'rest/api/endpoint/';
|
||||||
const requestId = 'request-id';
|
const requestId = 'request-id';
|
||||||
let itemRequest: ItemRequest;
|
let itemRequest: ItemRequest;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
configService = jasmine.createSpyObj('ConfigurationDataService', ['findByPropertyName']);
|
||||||
|
(configService.findByPropertyName as jasmine.Spy).and.callFake((propertyName: string) => {
|
||||||
|
switch (propertyName) {
|
||||||
|
case 'request.item.create.captcha':
|
||||||
|
return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
|
||||||
|
name: 'request.item.create.captcha',
|
||||||
|
values: ['true'],
|
||||||
|
}));
|
||||||
|
case 'request.item.grant.link.period':
|
||||||
|
return createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
|
||||||
|
name: 'request.item.grant.link.period',
|
||||||
|
values: ['FOREVER', '+1DAY', '+1MONTH'],
|
||||||
|
}));
|
||||||
|
default:
|
||||||
|
return createSuccessfulRemoteDataObject$(new ConfigurationProperty());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
authorizationDataService = jasmine.createSpyObj('authorizationService', {
|
||||||
|
isAuthorized: observableOf(false),
|
||||||
|
});
|
||||||
itemRequest = Object.assign(new ItemRequest(), {
|
itemRequest = Object.assign(new ItemRequest(), {
|
||||||
token: 'item-request-token',
|
token: 'item-request-token',
|
||||||
});
|
});
|
||||||
@@ -36,13 +67,42 @@ describe('ItemRequestDataService', () => {
|
|||||||
getEndpoint: observableOf(restApiEndpoint),
|
getEndpoint: observableOf(restApiEndpoint),
|
||||||
});
|
});
|
||||||
|
|
||||||
service = new ItemRequestDataService(requestService, rdbService, null, halService);
|
service = new ItemRequestDataService(requestService, rdbService, null, halService, configService, authorizationDataService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchBy', () => {
|
||||||
|
it('should use searchData to perform search operations', () => {
|
||||||
|
const searchMethod = 'testMethod';
|
||||||
|
const options = new FindListOptions();
|
||||||
|
|
||||||
|
const searchDataSpy = spyOn((service as any).searchData, 'searchBy').and.returnValue(observableOf(null));
|
||||||
|
|
||||||
|
service.searchBy(searchMethod, options);
|
||||||
|
|
||||||
|
expect(searchDataSpy).toHaveBeenCalledWith(
|
||||||
|
searchMethod,
|
||||||
|
options,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('requestACopy', () => {
|
describe('requestACopy', () => {
|
||||||
it('should send a POST request containing the provided item request', (done) => {
|
it('should send a POST request containing the provided item request', (done) => {
|
||||||
service.requestACopy(itemRequest).subscribe(() => {
|
const captchaPayload = 'payload';
|
||||||
expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestId, restApiEndpoint, itemRequest));
|
service.requestACopy(itemRequest, captchaPayload).subscribe(() => {
|
||||||
|
expect(requestService.send).toHaveBeenCalledWith(
|
||||||
|
new PostRequest(
|
||||||
|
requestId,
|
||||||
|
restApiEndpoint,
|
||||||
|
itemRequest,
|
||||||
|
{
|
||||||
|
headers: new HttpHeaders().set('x-captcha-payload', captchaPayload),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -56,14 +116,19 @@ describe('ItemRequestDataService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should send a PUT request containing the correct properties', (done) => {
|
it('should send a PUT request containing the correct properties', (done) => {
|
||||||
service.grant(itemRequest.token, email, true).subscribe(() => {
|
service.grant(itemRequest.token, email, true, '+1DAY').subscribe(() => {
|
||||||
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
|
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||||
method: RestRequestMethod.PUT,
|
method: RestRequestMethod.PUT,
|
||||||
|
href: `${restApiEndpoint}/${itemRequest.token}`,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
acceptRequest: true,
|
acceptRequest: true,
|
||||||
responseMessage: email.message,
|
responseMessage: email.message,
|
||||||
subject: email.subject,
|
subject: email.subject,
|
||||||
suggestOpenAccess: true,
|
suggestOpenAccess: true,
|
||||||
|
accessPeriod: '+1DAY',
|
||||||
|
}),
|
||||||
|
options: jasmine.objectContaining({
|
||||||
|
headers: jasmine.any(HttpHeaders),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
done();
|
done();
|
||||||
@@ -82,15 +147,70 @@ describe('ItemRequestDataService', () => {
|
|||||||
service.deny(itemRequest.token, email).subscribe(() => {
|
service.deny(itemRequest.token, email).subscribe(() => {
|
||||||
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
|
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||||
method: RestRequestMethod.PUT,
|
method: RestRequestMethod.PUT,
|
||||||
|
href: `${restApiEndpoint}/${itemRequest.token}`,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
acceptRequest: false,
|
acceptRequest: false,
|
||||||
responseMessage: email.message,
|
responseMessage: email.message,
|
||||||
subject: email.subject,
|
subject: email.subject,
|
||||||
suggestOpenAccess: false,
|
suggestOpenAccess: false,
|
||||||
|
accessPeriod: null,
|
||||||
|
}),
|
||||||
|
options: jasmine.objectContaining({
|
||||||
|
headers: jasmine.any(HttpHeaders),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('requestACopy', () => {
|
||||||
|
it('should send a POST request containing the provided item request', (done) => {
|
||||||
|
const captchaPayload = 'payload';
|
||||||
|
service.requestACopy(itemRequest, captchaPayload).subscribe(() => {
|
||||||
|
expect(requestService.send).toHaveBeenCalledWith(
|
||||||
|
new PostRequest(
|
||||||
|
requestId,
|
||||||
|
restApiEndpoint,
|
||||||
|
itemRequest,
|
||||||
|
{
|
||||||
|
headers: new HttpHeaders().set('x-captcha-payload', captchaPayload),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getConfiguredAccessPeriods', () => {
|
||||||
|
it('should return parsed integer values from config', () => {
|
||||||
|
service.getConfiguredAccessPeriods().subscribe(periods => {
|
||||||
|
expect(periods).toEqual(['FOREVER', '+1DAY', '+1MONTH']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('isProtectedByCaptcha', () => {
|
||||||
|
it('should return true when config value is "true"', () => {
|
||||||
|
const mockConfigProperty = {
|
||||||
|
name: 'request.item.create.captcha',
|
||||||
|
values: ['true'],
|
||||||
|
} as ConfigurationProperty;
|
||||||
|
service.isProtectedByCaptcha().subscribe(result => {
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canDownload', () => {
|
||||||
|
it('should check authorization for bitstream download', () => {
|
||||||
|
service.canDownload(MockBitstream1).subscribe(result => {
|
||||||
|
expect(authorizationDataService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanDownload, MockBitstream1.self);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -13,14 +13,27 @@ import {
|
|||||||
hasValue,
|
hasValue,
|
||||||
isNotEmpty,
|
isNotEmpty,
|
||||||
} from '../../shared/empty.util';
|
} from '../../shared/empty.util';
|
||||||
|
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||||
|
import { RequestParam } from '../cache/models/request-param.model';
|
||||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||||
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
||||||
|
import { Bitstream } from '../shared/bitstream.model';
|
||||||
|
import { ConfigurationProperty } from '../shared/configuration-property.model';
|
||||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
import { ItemRequest } from '../shared/item-request.model';
|
import { ItemRequest } from '../shared/item-request.model';
|
||||||
import { getFirstCompletedRemoteData } from '../shared/operators';
|
import { getFirstCompletedRemoteData } from '../shared/operators';
|
||||||
import { sendRequest } from '../shared/request.operators';
|
import { sendRequest } from '../shared/request.operators';
|
||||||
import { IdentifiableDataService } from './base/identifiable-data.service';
|
import { IdentifiableDataService } from './base/identifiable-data.service';
|
||||||
|
import {
|
||||||
|
SearchData,
|
||||||
|
SearchDataImpl,
|
||||||
|
} from './base/search-data';
|
||||||
|
import { ConfigurationDataService } from './configuration-data.service';
|
||||||
|
import { AuthorizationDataService } from './feature-authorization/authorization-data.service';
|
||||||
|
import { FeatureID } from './feature-authorization/feature-id';
|
||||||
|
import { FindListOptions } from './find-list-options.model';
|
||||||
|
import { PaginatedList } from './paginated-list.model';
|
||||||
import { RemoteData } from './remote-data';
|
import { RemoteData } from './remote-data';
|
||||||
import {
|
import {
|
||||||
PostRequest,
|
PostRequest,
|
||||||
@@ -34,14 +47,20 @@ import { RequestService } from './request.service';
|
|||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class ItemRequestDataService extends IdentifiableDataService<ItemRequest> {
|
export class ItemRequestDataService extends IdentifiableDataService<ItemRequest> implements SearchData<ItemRequest> {
|
||||||
|
|
||||||
|
private searchData: SearchDataImpl<ItemRequest>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected requestService: RequestService,
|
protected requestService: RequestService,
|
||||||
protected rdbService: RemoteDataBuildService,
|
protected rdbService: RemoteDataBuildService,
|
||||||
protected objectCache: ObjectCacheService,
|
protected objectCache: ObjectCacheService,
|
||||||
protected halService: HALEndpointService,
|
protected halService: HALEndpointService,
|
||||||
|
protected configService: ConfigurationDataService,
|
||||||
|
protected authorizationService: AuthorizationDataService,
|
||||||
) {
|
) {
|
||||||
super('itemrequests', requestService, rdbService, objectCache, halService);
|
super('itemrequests', requestService, rdbService, objectCache, halService);
|
||||||
|
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
|
||||||
}
|
}
|
||||||
|
|
||||||
getItemRequestEndpoint(): Observable<string> {
|
getItemRequestEndpoint(): Observable<string> {
|
||||||
@@ -61,17 +80,26 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
|
|||||||
/**
|
/**
|
||||||
* Request a copy of an item
|
* Request a copy of an item
|
||||||
* @param itemRequest
|
* @param itemRequest
|
||||||
|
* @param captchaPayload payload of captcha verification
|
||||||
*/
|
*/
|
||||||
requestACopy(itemRequest: ItemRequest): Observable<RemoteData<ItemRequest>> {
|
requestACopy(itemRequest: ItemRequest, captchaPayload: string): Observable<RemoteData<ItemRequest>> {
|
||||||
const requestId = this.requestService.generateRequestId();
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
|
||||||
const href$ = this.getItemRequestEndpoint();
|
const href$ = this.getItemRequestEndpoint();
|
||||||
|
|
||||||
|
// Inject captcha payload into headers
|
||||||
|
const options: HttpOptions = Object.create({});
|
||||||
|
if (captchaPayload) {
|
||||||
|
let headers = new HttpHeaders();
|
||||||
|
headers = headers.set('x-captcha-payload', captchaPayload);
|
||||||
|
options.headers = headers;
|
||||||
|
}
|
||||||
|
|
||||||
href$.pipe(
|
href$.pipe(
|
||||||
find((href: string) => hasValue(href)),
|
find((href: string) => hasValue(href)),
|
||||||
map((href: string) => {
|
map((href: string) => {
|
||||||
const request = new PostRequest(requestId, href, itemRequest);
|
const request = new PostRequest(requestId, href, itemRequest, options);
|
||||||
this.requestService.send(request);
|
this.requestService.send(request, false);
|
||||||
}),
|
}),
|
||||||
).subscribe();
|
).subscribe();
|
||||||
|
|
||||||
@@ -94,9 +122,10 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
|
|||||||
* @param token Token of the {@link ItemRequest}
|
* @param token Token of the {@link ItemRequest}
|
||||||
* @param email Email to send back to the user requesting the item
|
* @param email Email to send back to the user requesting the item
|
||||||
* @param suggestOpenAccess Whether or not to suggest the item to become open access
|
* @param suggestOpenAccess Whether or not to suggest the item to become open access
|
||||||
|
* @param accessPeriod How long in seconds to grant access, from the decision date (only applies to links, not attachments)
|
||||||
*/
|
*/
|
||||||
grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false): Observable<RemoteData<ItemRequest>> {
|
grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false, accessPeriod: string = null): Observable<RemoteData<ItemRequest>> {
|
||||||
return this.process(token, email, true, suggestOpenAccess);
|
return this.process(token, email, true, suggestOpenAccess, accessPeriod);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,8 +134,9 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
|
|||||||
* @param email Email to send back to the user requesting the item
|
* @param email Email to send back to the user requesting the item
|
||||||
* @param grant Grant or deny the request (true = grant, false = deny)
|
* @param grant Grant or deny the request (true = grant, false = deny)
|
||||||
* @param suggestOpenAccess Whether or not to suggest the item to become open access
|
* @param suggestOpenAccess Whether or not to suggest the item to become open access
|
||||||
|
* @param accessPeriod How long in seconds to grant access, from the decision date (only applies to links, not attachments)
|
||||||
*/
|
*/
|
||||||
process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false): Observable<RemoteData<ItemRequest>> {
|
process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false, accessPeriod: string = null): Observable<RemoteData<ItemRequest>> {
|
||||||
const requestId = this.requestService.generateRequestId();
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
|
||||||
this.getItemRequestEndpointByToken(token).pipe(
|
this.getItemRequestEndpointByToken(token).pipe(
|
||||||
@@ -121,6 +151,7 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
|
|||||||
responseMessage: email.message,
|
responseMessage: email.message,
|
||||||
subject: email.subject,
|
subject: email.subject,
|
||||||
suggestOpenAccess,
|
suggestOpenAccess,
|
||||||
|
accessPeriod: accessPeriod,
|
||||||
}), options);
|
}), options);
|
||||||
}),
|
}),
|
||||||
sendRequest(this.requestService),
|
sendRequest(this.requestService),
|
||||||
@@ -128,4 +159,81 @@ export class ItemRequestDataService extends IdentifiableDataService<ItemRequest>
|
|||||||
|
|
||||||
return this.rdbService.buildFromRequestUUID(requestId);
|
return this.rdbService.buildFromRequestUUID(requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a sanitized item request using the searchBy method and the access token sent to the original requester.
|
||||||
|
*
|
||||||
|
* @param accessToken access token contained in the secure link sent to a requester
|
||||||
|
*/
|
||||||
|
getSanitizedRequestByAccessToken(accessToken: string): Observable<RemoteData<ItemRequest>> {
|
||||||
|
const findListOptions = Object.assign({}, new FindListOptions(), {
|
||||||
|
searchParams: [
|
||||||
|
new RequestParam('accessToken', accessToken),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const hrefObs = this.getSearchByHref(
|
||||||
|
'byAccessToken',
|
||||||
|
findListOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.searchData.findByHref(
|
||||||
|
hrefObs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<ItemRequest>[]): Observable<RemoteData<PaginatedList<ItemRequest>>> {
|
||||||
|
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get configured access periods (in seconds) to populate the dropdown in the item request approval form
|
||||||
|
* if the 'send secure link' feature is configured.
|
||||||
|
* Expects integer values, conversion to number is done in this processing
|
||||||
|
*/
|
||||||
|
getConfiguredAccessPeriods(): Observable<string[]> {
|
||||||
|
return this.configService.findByPropertyName('request.item.grant.link.period').pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
map((propertyRD: RemoteData<ConfigurationProperty>) => propertyRD.hasSucceeded ? propertyRD.payload.values : []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the request copy form protected by a captcha? This will be used to decide whether to render the captcha
|
||||||
|
* component in bitstream-request-a-copy-page component
|
||||||
|
*/
|
||||||
|
isProtectedByCaptcha(): Observable<boolean> {
|
||||||
|
return this.configService.findByPropertyName('request.item.create.captcha').pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
map((rd) => {
|
||||||
|
if (rd.hasSucceeded) {
|
||||||
|
return rd.payload.values.length > 0 && rd.payload.values[0] === 'true';
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the HREF for a specific object's search method with given options object
|
||||||
|
*
|
||||||
|
* @param searchMethod The search method for the object
|
||||||
|
* @param options The [[FindListOptions]] object
|
||||||
|
* @return {Observable<string>}
|
||||||
|
* Return an observable that emits created HREF
|
||||||
|
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
|
||||||
|
*/
|
||||||
|
public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig<ItemRequest>[]): Observable<string> {
|
||||||
|
return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorization check to see if the user already has download access to the given bitstream.
|
||||||
|
* Wrapped in this service to give it a central place and make it easy to mock for testing.
|
||||||
|
*
|
||||||
|
* @param bitstream The bitstream to be downloaded
|
||||||
|
* @return {Observable<boolean>} true if user may download, false if not
|
||||||
|
*/
|
||||||
|
canDownload(bitstream: Bitstream): Observable<boolean> {
|
||||||
|
return this.authorizationService.isAuthorized(FeatureID.CanDownload, bitstream?.self);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
37
src/app/core/data/proof-of-work-captcha-data.service.ts
Normal file
37
src/app/core/data/proof-of-work-captcha-data.service.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for retrieving captcha challenge data, so proof-of-work calculations can be performed
|
||||||
|
* and returned with protected form data.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ProofOfWorkCaptchaDataService {
|
||||||
|
|
||||||
|
private linkPath = 'captcha';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private halService: HALEndpointService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the endpoint for retrieving a new captcha challenge, to be passed
|
||||||
|
* to the Altcha captcha component as an input property
|
||||||
|
*/
|
||||||
|
public getChallengeHref(): Observable<string> {
|
||||||
|
return this.getEndpoint().pipe(
|
||||||
|
map((endpoint) => endpoint + '/challenge'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base CAPTCHA endpoint URL
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getEndpoint(): Observable<string> {
|
||||||
|
return this.halService.getEndpoint(this.linkPath);
|
||||||
|
}
|
||||||
|
}
|
@@ -46,6 +46,7 @@ import { CoreState } from '../core-state.model';
|
|||||||
import { ChangeAnalyzer } from '../data/change-analyzer';
|
import { ChangeAnalyzer } from '../data/change-analyzer';
|
||||||
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
|
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
|
||||||
import { FindListOptions } from '../data/find-list-options.model';
|
import { FindListOptions } from '../data/find-list-options.model';
|
||||||
|
import { RemoteData } from '../data/remote-data';
|
||||||
import {
|
import {
|
||||||
PatchRequest,
|
PatchRequest,
|
||||||
PostRequest,
|
PostRequest,
|
||||||
@@ -351,6 +352,21 @@ describe('EPersonDataService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mergeEPersonDataWithToken', () => {
|
||||||
|
const uuid = '1234-5678-9012-3456';
|
||||||
|
const token = 'abcd-efgh-ijkl-mnop';
|
||||||
|
const metadataKey = 'eperson.firstname';
|
||||||
|
beforeEach(() => {
|
||||||
|
spyOn(service, 'mergeEPersonDataWithToken').and.returnValue(createSuccessfulRemoteDataObject$(EPersonMock));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge EPerson data with token', () => {
|
||||||
|
service.mergeEPersonDataWithToken(uuid, token, metadataKey).subscribe((result: RemoteData<EPerson>) => {
|
||||||
|
expect(result.hasSucceeded).toBeTrue();
|
||||||
|
});
|
||||||
|
expect(service.mergeEPersonDataWithToken).toHaveBeenCalledWith(uuid, token, metadataKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
class DummyChangeAnalyzer implements ChangeAnalyzer<Item> {
|
class DummyChangeAnalyzer implements ChangeAnalyzer<Item> {
|
||||||
|
@@ -394,6 +394,32 @@ export class EPersonDataService extends IdentifiableDataService<EPerson> impleme
|
|||||||
return this.rdbService.buildFromRequestUUID(requestId);
|
return this.rdbService.buildFromRequestUUID(requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a POST request to merge registration data related to the provided registration-token,
|
||||||
|
* into the eperson related to the provided uuid
|
||||||
|
* @param uuid the user uuid
|
||||||
|
* @param token registration-token
|
||||||
|
* @param metadataKey metadata key of the metadata field that should be overriden
|
||||||
|
*/
|
||||||
|
mergeEPersonDataWithToken(uuid: string, token: string, metadataKey?: string): Observable<RemoteData<EPerson>> {
|
||||||
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
const hrefObs = this.getBrowseEndpoint().pipe(
|
||||||
|
map((href: string) =>
|
||||||
|
hasValue(metadataKey)
|
||||||
|
? `${href}/${uuid}?token=${token}&override=${metadataKey}`
|
||||||
|
: `${href}/${uuid}?token=${token}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
hrefObs.pipe(
|
||||||
|
find((href: string) => hasValue(href)),
|
||||||
|
).subscribe((href: string) => {
|
||||||
|
const request = new PostRequest(requestId, href);
|
||||||
|
this.requestService.send(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.rdbService.buildFromRequestUUID(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new object on the server, and store the response in the object cache
|
* Create a new object on the server, and store the response in the object cache
|
||||||
|
@@ -176,7 +176,6 @@ export const models =
|
|||||||
ResearcherProfile,
|
ResearcherProfile,
|
||||||
OrcidQueue,
|
OrcidQueue,
|
||||||
OrcidHistory,
|
OrcidHistory,
|
||||||
AccessStatusObject,
|
|
||||||
IdentifierData,
|
IdentifierData,
|
||||||
Subscription,
|
Subscription,
|
||||||
ItemRequest,
|
ItemRequest,
|
||||||
|
@@ -1,15 +1,10 @@
|
|||||||
import {
|
import { Injectable } from '@angular/core';
|
||||||
Inject,
|
|
||||||
Injectable,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { CookieAttributes } from 'js-cookie';
|
import { CookieAttributes } from 'js-cookie';
|
||||||
import {
|
import {
|
||||||
Observable,
|
Observable,
|
||||||
Subject,
|
Subject,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
import { REQUEST } from '../../../express.tokens';
|
|
||||||
|
|
||||||
export interface ICookieService {
|
export interface ICookieService {
|
||||||
readonly cookies$: Observable<{ readonly [key: string]: any }>;
|
readonly cookies$: Observable<{ readonly [key: string]: any }>;
|
||||||
|
|
||||||
@@ -27,9 +22,6 @@ export abstract class CookieService implements ICookieService {
|
|||||||
protected readonly cookieSource = new Subject<{ readonly [key: string]: any }>();
|
protected readonly cookieSource = new Subject<{ readonly [key: string]: any }>();
|
||||||
public readonly cookies$ = this.cookieSource.asObservable();
|
public readonly cookies$ = this.cookieSource.asObservable();
|
||||||
|
|
||||||
constructor(@Inject(REQUEST) protected req: any) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract set(name: string, value: any, options?: CookieAttributes): void;
|
public abstract set(name: string, value: any, options?: CookieAttributes): void;
|
||||||
|
|
||||||
public abstract remove(name: string, options?: CookieAttributes): void;
|
public abstract remove(name: string, options?: CookieAttributes): void;
|
||||||
|
@@ -1,6 +1,10 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
} from '@angular/core';
|
||||||
import { CookieAttributes } from 'js-cookie';
|
import { CookieAttributes } from 'js-cookie';
|
||||||
|
|
||||||
|
import { REQUEST } from '../../../express.tokens';
|
||||||
import {
|
import {
|
||||||
CookieService,
|
CookieService,
|
||||||
ICookieService,
|
ICookieService,
|
||||||
@@ -9,6 +13,10 @@ import {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ServerCookieService extends CookieService implements ICookieService {
|
export class ServerCookieService extends CookieService implements ICookieService {
|
||||||
|
|
||||||
|
constructor(@Inject(REQUEST) protected req: any) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
public set(name: string, value: any, options?: CookieAttributes): void {
|
public set(name: string, value: any, options?: CookieAttributes): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@@ -4,6 +4,8 @@ import {
|
|||||||
inheritSerialization,
|
inheritSerialization,
|
||||||
} from 'cerialize';
|
} from 'cerialize';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
import { AccessStatusObject } from 'src/app/shared/object-collection/shared/badges/access-status-badge/access-status.model';
|
||||||
|
import { ACCESS_STATUS } from 'src/app/shared/object-collection/shared/badges/access-status-badge/access-status.resource-type';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
link,
|
link,
|
||||||
@@ -52,6 +54,7 @@ export class Bitstream extends DSpaceObject implements ChildHALResource {
|
|||||||
format: HALLink;
|
format: HALLink;
|
||||||
content: HALLink;
|
content: HALLink;
|
||||||
thumbnail: HALLink;
|
thumbnail: HALLink;
|
||||||
|
accessStatus: HALLink;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +78,13 @@ export class Bitstream extends DSpaceObject implements ChildHALResource {
|
|||||||
@link(BUNDLE)
|
@link(BUNDLE)
|
||||||
bundle?: Observable<RemoteData<Bundle>>;
|
bundle?: Observable<RemoteData<Bundle>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The access status for this Bitstream
|
||||||
|
* Will be undefined unless the access status {@link HALLink} has been resolved.
|
||||||
|
*/
|
||||||
|
@link(ACCESS_STATUS, false, 'accessStatus')
|
||||||
|
accessStatus?: Observable<RemoteData<AccessStatusObject>>;
|
||||||
|
|
||||||
getParentLinkKey(): keyof this['_links'] {
|
getParentLinkKey(): keyof this['_links'] {
|
||||||
return 'format';
|
return 'format';
|
||||||
}
|
}
|
||||||
|
@@ -44,4 +44,10 @@ export enum Context {
|
|||||||
Bitstream = 'bitstream',
|
Bitstream = 'bitstream',
|
||||||
|
|
||||||
CoarNotify = 'coarNotify',
|
CoarNotify = 'coarNotify',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Edit Metadata field Context values that are used in the Edit Item Metadata tab.
|
||||||
|
*/
|
||||||
|
AddMetadata = 'addMetadata',
|
||||||
|
EditMetadata = 'editMetadata',
|
||||||
}
|
}
|
||||||
|
@@ -80,7 +80,19 @@ export class ItemRequest implements CacheableObject {
|
|||||||
*/
|
*/
|
||||||
@autoserialize
|
@autoserialize
|
||||||
bitstreamId: string;
|
bitstreamId: string;
|
||||||
|
/**
|
||||||
|
* Access token of the request (read-only)
|
||||||
|
*/
|
||||||
|
@autoserialize
|
||||||
|
accessToken: string;
|
||||||
|
/**
|
||||||
|
* Access expiry date of the request
|
||||||
|
*/
|
||||||
|
@autoserialize
|
||||||
|
accessExpiry: string;
|
||||||
|
|
||||||
|
@autoserialize
|
||||||
|
accessExpired: boolean;
|
||||||
/**
|
/**
|
||||||
* The {@link HALLink}s for this ItemRequest
|
* The {@link HALLink}s for this ItemRequest
|
||||||
*/
|
*/
|
||||||
|
@@ -130,7 +130,7 @@ export class Item extends DSpaceObject implements ChildHALResource, HandleObject
|
|||||||
* The access status for this Item
|
* The access status for this Item
|
||||||
* Will be undefined unless the access status {@link HALLink} has been resolved.
|
* Will be undefined unless the access status {@link HALLink} has been resolved.
|
||||||
*/
|
*/
|
||||||
@link(ACCESS_STATUS)
|
@link(ACCESS_STATUS, false, 'accessStatus')
|
||||||
accessStatus?: Observable<RemoteData<AccessStatusObject>>;
|
accessStatus?: Observable<RemoteData<AccessStatusObject>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -23,4 +23,9 @@ export class MediaViewerItem {
|
|||||||
* Incoming Bitsream thumbnail
|
* Incoming Bitsream thumbnail
|
||||||
*/
|
*/
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access token, if accessed via a Request-a-Copy link
|
||||||
|
*/
|
||||||
|
accessToken: string;
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +1,27 @@
|
|||||||
|
// eslint-disable-next-line max-classes-per-file
|
||||||
|
import { AuthRegistrationType } from '../auth/models/auth.registration-type';
|
||||||
import { typedObject } from '../cache/builders/build-decorators';
|
import { typedObject } from '../cache/builders/build-decorators';
|
||||||
|
import { MetadataValue } from './metadata.models';
|
||||||
import { REGISTRATION } from './registration.resource-type';
|
import { REGISTRATION } from './registration.resource-type';
|
||||||
import { ResourceType } from './resource-type';
|
import { ResourceType } from './resource-type';
|
||||||
import { UnCacheableObject } from './uncacheable-object.model';
|
import { UnCacheableObject } from './uncacheable-object.model';
|
||||||
|
|
||||||
|
export class RegistrationDataMetadataMap {
|
||||||
|
[key: string]: RegistrationDataMetadataValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RegistrationDataMetadataValue extends MetadataValue {
|
||||||
|
overrides?: string;
|
||||||
|
}
|
||||||
@typedObject
|
@typedObject
|
||||||
export class Registration implements UnCacheableObject {
|
export class Registration implements UnCacheableObject {
|
||||||
static type = REGISTRATION;
|
static type = REGISTRATION;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unique identifier of this registration data
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The object type
|
* The object type
|
||||||
*/
|
*/
|
||||||
@@ -29,8 +45,24 @@ export class Registration implements UnCacheableObject {
|
|||||||
* The token linked to the registration
|
* The token linked to the registration
|
||||||
*/
|
*/
|
||||||
groupNames: string[];
|
groupNames: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The token linked to the registration
|
* The token linked to the registration
|
||||||
*/
|
*/
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registration type (e.g. orcid, shibboleth, etc.)
|
||||||
|
*/
|
||||||
|
registrationType?: AuthRegistrationType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The netId of the user (e.g. for ORCID - <:orcid>)
|
||||||
|
*/
|
||||||
|
netId?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata involved during the registration process
|
||||||
|
*/
|
||||||
|
registrationMetadata?: RegistrationDataMetadataMap;
|
||||||
}
|
}
|
||||||
|
@@ -37,6 +37,7 @@ import { SearchService } from './search.service';
|
|||||||
import { SearchConfigurationService } from './search-configuration.service';
|
import { SearchConfigurationService } from './search-configuration.service';
|
||||||
import anything = jasmine.anything;
|
import anything = jasmine.anything;
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: '',
|
template: '',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
@@ -355,7 +355,7 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send search event to rest api using angularitics
|
* Send search event to rest api using angulartics2
|
||||||
* @param config Paginated search options used
|
* @param config Paginated search options used
|
||||||
* @param searchQueryResponse The response objects of the performed search
|
* @param searchQueryResponse The response objects of the performed search
|
||||||
* @param clickedObject Optional UUID of an object a search was performed and clicked for
|
* @param clickedObject Optional UUID of an object a search was performed and clicked for
|
||||||
@@ -367,7 +367,7 @@ export class SearchService {
|
|||||||
const appliedFilter = appliedFilters[i];
|
const appliedFilter = appliedFilters[i];
|
||||||
filters.push(appliedFilter);
|
filters.push(appliedFilter);
|
||||||
}
|
}
|
||||||
this.angulartics2.eventTrack.next({
|
const searchTrackObject = {
|
||||||
action: 'search',
|
action: 'search',
|
||||||
properties: {
|
properties: {
|
||||||
searchOptions: config,
|
searchOptions: config,
|
||||||
@@ -384,7 +384,9 @@ export class SearchService {
|
|||||||
filters: filters,
|
filters: filters,
|
||||||
clickedObject,
|
clickedObject,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
this.angulartics2.eventTrack.next(searchTrackObject);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -8,4 +8,5 @@ export enum ViewMode {
|
|||||||
DetailedListElement = 'detailed',
|
DetailedListElement = 'detailed',
|
||||||
StandalonePage = 'standalone',
|
StandalonePage = 'standalone',
|
||||||
Table = 'table',
|
Table = 'table',
|
||||||
|
GeospatialMap = 'geospatial-map'
|
||||||
}
|
}
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
@for (mdValue of form.fields[mdField]; track mdValue; let idx = $index) {
|
@for (mdValue of form.fields[mdField]; track mdValue; let idx = $index) {
|
||||||
<ds-dso-edit-metadata-value role="presentation"
|
<ds-dso-edit-metadata-value role="presentation"
|
||||||
[dso]="dso"
|
[dso]="dso"
|
||||||
|
[context]="Context.EditMetadata"
|
||||||
[mdValue]="mdValue"
|
[mdValue]="mdValue"
|
||||||
[mdField]="mdField"
|
[mdField]="mdField"
|
||||||
[dsoType]="dsoType"
|
[dsoType]="dsoType"
|
||||||
|
@@ -15,6 +15,7 @@ import {
|
|||||||
Observable,
|
Observable,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
|
import { Context } from '../../../core/shared/context.model';
|
||||||
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
||||||
import {
|
import {
|
||||||
DsoEditMetadataChangeType,
|
DsoEditMetadataChangeType,
|
||||||
@@ -78,6 +79,8 @@ export class DsoEditMetadataFieldValuesComponent {
|
|||||||
*/
|
*/
|
||||||
public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
|
public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
|
||||||
|
|
||||||
|
public readonly Context = Context;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop a value into a new position
|
* Drop a value into a new position
|
||||||
* Update the form's value array for the current field to match the dropped position
|
* Update the form's value array for the current field to match the dropped position
|
||||||
|
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
import { Context } from '../../../core/shared/context.model';
|
||||||
|
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
||||||
|
import { DsoEditMetadataValue } from '../dso-edit-metadata-form';
|
||||||
|
import { EditMetadataValueFieldType } from './dso-edit-metadata-field-type.enum';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base component for editing metadata fields.
|
||||||
|
*
|
||||||
|
* This abstract component is only designed to contain the common `@Input()` & `@Output()` fields, that the
|
||||||
|
* {@link DsoEditMetadataValueFieldLoaderComponent} passes to its dynamically generated components. This class should
|
||||||
|
* not contain any methods or any other type of logic. Such logic should instead be created in
|
||||||
|
* {@link DsoEditMetadataFieldService}.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-abstract-dso-edit-metadata-value-field',
|
||||||
|
template: '',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export abstract class AbstractDsoEditMetadataValueFieldComponent {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The optional context
|
||||||
|
*/
|
||||||
|
@Input() context: Context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link DSpaceObject}
|
||||||
|
*/
|
||||||
|
@Input() dso: DSpaceObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of the DSO, used to determines i18n messages
|
||||||
|
*/
|
||||||
|
@Input() dsoType: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of the field
|
||||||
|
*/
|
||||||
|
@Input() type: EditMetadataValueFieldType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata field
|
||||||
|
*/
|
||||||
|
@Input() mdField: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editable metadata value to show
|
||||||
|
*/
|
||||||
|
@Input() mdValue: DsoEditMetadataValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits when the user clicked confirm
|
||||||
|
*/
|
||||||
|
@Output() confirm: EventEmitter<boolean> = new EventEmitter();
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,61 @@
|
|||||||
|
@if (mdValue.editing) {
|
||||||
|
@if ((isAuthorityControlled$ | async) !== true || (enabledFreeTextEditing && (isSuggesterVocabulary$ | async) !== true)) {
|
||||||
|
<textarea class="form-control" rows="5" [(ngModel)]="mdValue.newValue.value"
|
||||||
|
[attr.aria-label]="(dsoType + '.edit.metadata.edit.value') | translate"
|
||||||
|
[dsDebounce]="300" (onDebounce)="confirm.emit(false)">
|
||||||
|
</textarea>
|
||||||
|
}
|
||||||
|
@if ((isScrollableVocabulary$ | async) && !enabledFreeTextEditing) {
|
||||||
|
<ds-dynamic-scrollable-dropdown [bindId]="mdField"
|
||||||
|
[group]="group"
|
||||||
|
[model]="getModel()"
|
||||||
|
(change)="onChangeAuthorityField($event)">
|
||||||
|
</ds-dynamic-scrollable-dropdown>
|
||||||
|
}
|
||||||
|
@if (((isHierarchicalVocabulary$ | async) && !enabledFreeTextEditing) || (isSuggesterVocabulary$ | async)) {
|
||||||
|
<ds-dynamic-onebox
|
||||||
|
[group]="group"
|
||||||
|
[model]="getModel()"
|
||||||
|
(change)="onChangeAuthorityField($event)">
|
||||||
|
</ds-dynamic-onebox>
|
||||||
|
}
|
||||||
|
@if ((isHierarchicalVocabulary$ | async) || (isScrollableVocabulary$ | async)) {
|
||||||
|
<button class="btn btn-secondary w-100 mt-2"
|
||||||
|
[title]="enabledFreeTextEditing ? dsoType + '.edit.metadata.edit.buttons.disable-free-text-editing' : dsoType + '.edit.metadata.edit.buttons.enable-free-text-editing' | translate"
|
||||||
|
(click)="toggleFreeTextEdition()">
|
||||||
|
<i class="fas fa-fw" [ngClass]="enabledFreeTextEditing ? 'fa-lock' : 'fa-unlock'"></i>
|
||||||
|
{{ (enabledFreeTextEditing ? dsoType + '.edit.metadata.edit.buttons.disable-free-text-editing' : dsoType + '.edit.metadata.edit.buttons.enable-free-text-editing') | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if ((isAuthorityControlled$ | async) && (isSuggesterVocabulary$ | async)) {
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="btn-group w-75">
|
||||||
|
<i dsAuthorityConfidenceState
|
||||||
|
class="fas fa-fw p-0 me-1 mt-auto mb-auto"
|
||||||
|
aria-hidden="true"
|
||||||
|
[authorityValue]="mdValue.newValue.confidence"
|
||||||
|
[iconMode]="true"
|
||||||
|
></i>
|
||||||
|
<input class="form-control form-outline" data-test="authority-input" [(ngModel)]="mdValue.newValue.authority"
|
||||||
|
[disabled]="!editingAuthority"
|
||||||
|
[attr.aria-label]="(dsoType + '.edit.metadata.edit.authority.key') | translate"
|
||||||
|
(change)="onChangeAuthorityKey()"/>
|
||||||
|
@if (editingAuthority) {
|
||||||
|
<button class="btn btn-outline-success btn-sm ng-star-inserted" id="metadata-confirm-btn"
|
||||||
|
[title]="dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate"
|
||||||
|
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate }}"
|
||||||
|
(click)="onChangeEditingAuthorityStatus(false)">
|
||||||
|
<i class="fas fa-lock-open fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button class="btn btn-outline-secondary btn-sm ng-star-inserted" id="metadata-confirm-btn"
|
||||||
|
[title]="dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate"
|
||||||
|
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate }}"
|
||||||
|
(click)="onChangeEditingAuthorityStatus(true)">
|
||||||
|
<i class="fas fa-lock fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,360 @@
|
|||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
waitForAsync,
|
||||||
|
} from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { ItemDataService } from '../../../../core/data/item-data.service';
|
||||||
|
import { MetadataField } from '../../../../core/metadata/metadata-field.model';
|
||||||
|
import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model';
|
||||||
|
import { RegistryService } from '../../../../core/registry/registry.service';
|
||||||
|
import { Collection } from '../../../../core/shared/collection.model';
|
||||||
|
import { ConfidenceType } from '../../../../core/shared/confidence-type';
|
||||||
|
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
|
||||||
|
import { Item } from '../../../../core/shared/item.model';
|
||||||
|
import { MetadataValue } from '../../../../core/shared/metadata.models';
|
||||||
|
import { Vocabulary } from '../../../../core/submission/vocabularies/models/vocabulary.model';
|
||||||
|
import { VocabularyService } from '../../../../core/submission/vocabularies/vocabulary.service';
|
||||||
|
import { DynamicOneboxModel } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model';
|
||||||
|
import { DsDynamicScrollableDropdownComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component';
|
||||||
|
import { DynamicScrollableDropdownModel } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model';
|
||||||
|
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
|
||||||
|
import { createPaginatedList } from '../../../../shared/testing/utils.test';
|
||||||
|
import { VocabularyServiceStub } from '../../../../shared/testing/vocabulary-service.stub';
|
||||||
|
import { DsoEditMetadataValue } from '../../dso-edit-metadata-form';
|
||||||
|
import { DsoEditMetadataAuthorityFieldComponent } from './dso-edit-metadata-authority-field.component';
|
||||||
|
|
||||||
|
describe('DsoEditMetadataAuthorityFieldComponent', () => {
|
||||||
|
let component: DsoEditMetadataAuthorityFieldComponent;
|
||||||
|
let fixture: ComponentFixture<DsoEditMetadataAuthorityFieldComponent>;
|
||||||
|
|
||||||
|
let vocabularyService: any;
|
||||||
|
let itemService: ItemDataService;
|
||||||
|
let registryService: RegistryService;
|
||||||
|
let notificationsService: NotificationsService;
|
||||||
|
|
||||||
|
let dso: DSpaceObject;
|
||||||
|
|
||||||
|
const collection = Object.assign(new Collection(), {
|
||||||
|
uuid: 'fake-uuid',
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = Object.assign(new Item(), {
|
||||||
|
_links: {
|
||||||
|
self: { href: 'fake-item-url/item' },
|
||||||
|
},
|
||||||
|
id: 'item',
|
||||||
|
uuid: 'item',
|
||||||
|
owningCollection: createSuccessfulRemoteDataObject$(collection),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockVocabularyScrollable: Vocabulary = {
|
||||||
|
id: 'scrollable',
|
||||||
|
name: 'scrollable',
|
||||||
|
scrollable: true,
|
||||||
|
hierarchical: false,
|
||||||
|
preloadLevel: 0,
|
||||||
|
type: 'vocabulary',
|
||||||
|
_links: {
|
||||||
|
self: {
|
||||||
|
href: 'self',
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
href: 'entries',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockVocabularyHierarchical: Vocabulary = {
|
||||||
|
id: 'hierarchical',
|
||||||
|
name: 'hierarchical',
|
||||||
|
scrollable: false,
|
||||||
|
hierarchical: true,
|
||||||
|
preloadLevel: 2,
|
||||||
|
type: 'vocabulary',
|
||||||
|
_links: {
|
||||||
|
self: {
|
||||||
|
href: 'self',
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
href: 'entries',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockVocabularySuggester: Vocabulary = {
|
||||||
|
id: 'suggester',
|
||||||
|
name: 'suggester',
|
||||||
|
scrollable: false,
|
||||||
|
hierarchical: false,
|
||||||
|
preloadLevel: 0,
|
||||||
|
type: 'vocabulary',
|
||||||
|
_links: {
|
||||||
|
self: {
|
||||||
|
href: 'self',
|
||||||
|
},
|
||||||
|
entries: {
|
||||||
|
href: 'entries',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let editMetadataValue: DsoEditMetadataValue;
|
||||||
|
let metadataValue: MetadataValue;
|
||||||
|
let metadataSchema: MetadataSchema;
|
||||||
|
let metadataFields: MetadataField[];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
itemService = jasmine.createSpyObj('itemService', {
|
||||||
|
findByHref: createSuccessfulRemoteDataObject$(item),
|
||||||
|
});
|
||||||
|
vocabularyService = new VocabularyServiceStub();
|
||||||
|
registryService = jasmine.createSpyObj('registryService', {
|
||||||
|
queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)),
|
||||||
|
});
|
||||||
|
notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']);
|
||||||
|
|
||||||
|
metadataValue = Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Regular Name',
|
||||||
|
language: 'en',
|
||||||
|
place: 0,
|
||||||
|
authority: undefined,
|
||||||
|
});
|
||||||
|
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
||||||
|
metadataSchema = Object.assign(new MetadataSchema(), {
|
||||||
|
id: 0,
|
||||||
|
prefix: 'metadata',
|
||||||
|
namespace: 'https://example.com/',
|
||||||
|
});
|
||||||
|
metadataFields = [
|
||||||
|
Object.assign(new MetadataField(), {
|
||||||
|
id: 0,
|
||||||
|
element: 'regular',
|
||||||
|
qualifier: null,
|
||||||
|
schema: createSuccessfulRemoteDataObject$(metadataSchema),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
dso = Object.assign(new DSpaceObject(), {
|
||||||
|
_links: {
|
||||||
|
self: { href: 'fake-dso-url/dso' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
DsoEditMetadataAuthorityFieldComponent,
|
||||||
|
TranslateModule.forRoot(),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: VocabularyService, useValue: vocabularyService },
|
||||||
|
{ provide: ItemDataService, useValue: itemService },
|
||||||
|
{ provide: RegistryService, useValue: registryService },
|
||||||
|
{ provide: NotificationsService, useValue: notificationsService },
|
||||||
|
],
|
||||||
|
}).overrideComponent(DsoEditMetadataAuthorityFieldComponent, {
|
||||||
|
remove: {
|
||||||
|
imports: [
|
||||||
|
DsDynamicScrollableDropdownComponent,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(DsoEditMetadataAuthorityFieldComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
component.mdValue = editMetadataValue;
|
||||||
|
component.dso = dso;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the metadata field uses a scrollable vocabulary and is editing', () => {
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
spyOn(vocabularyService, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyScrollable));
|
||||||
|
metadataValue = Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Authority Controlled value',
|
||||||
|
language: 'en',
|
||||||
|
place: 0,
|
||||||
|
authority: null,
|
||||||
|
});
|
||||||
|
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
||||||
|
editMetadataValue.editing = true;
|
||||||
|
component.mdValue = editMetadataValue;
|
||||||
|
component.mdField = 'metadata.scrollable';
|
||||||
|
component.ngOnInit();
|
||||||
|
fixture.detectChanges();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should render the DsDynamicScrollableDropdownComponent', () => {
|
||||||
|
expect(vocabularyService.getVocabularyByMetadataAndCollection).toHaveBeenCalled();
|
||||||
|
expect(fixture.debugElement.query(By.css('ds-dynamic-scrollable-dropdown'))).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getModel should return a DynamicScrollableDropdownModel', () => {
|
||||||
|
const model = component.getModel();
|
||||||
|
|
||||||
|
expect(model instanceof DynamicScrollableDropdownModel).toBe(true);
|
||||||
|
expect(model.vocabularyOptions.name).toBe(mockVocabularyScrollable.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the metadata field uses a hierarchical vocabulary and is editing', () => {
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
spyOn(vocabularyService, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyHierarchical));
|
||||||
|
metadataValue = Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Authority Controlled value',
|
||||||
|
language: 'en',
|
||||||
|
place: 0,
|
||||||
|
authority: null,
|
||||||
|
});
|
||||||
|
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
||||||
|
editMetadataValue.editing = true;
|
||||||
|
component.mdValue = editMetadataValue;
|
||||||
|
component.mdField = 'metadata.hierarchical';
|
||||||
|
component.ngOnInit();
|
||||||
|
fixture.detectChanges();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should render the DsDynamicOneboxComponent', () => {
|
||||||
|
expect(vocabularyService.getVocabularyByMetadataAndCollection).toHaveBeenCalled();
|
||||||
|
expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getModel should return a DynamicOneboxModel', () => {
|
||||||
|
const model = component.getModel();
|
||||||
|
|
||||||
|
expect(model instanceof DynamicOneboxModel).toBe(true);
|
||||||
|
expect(model.vocabularyOptions.name).toBe(mockVocabularyHierarchical.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the metadata field uses a suggester vocabulary and is editing', () => {
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
spyOn(vocabularyService, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularySuggester));
|
||||||
|
spyOn(component.confirm, 'emit');
|
||||||
|
metadataValue = Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Authority Controlled value',
|
||||||
|
language: 'en',
|
||||||
|
place: 0,
|
||||||
|
authority: 'authority-key',
|
||||||
|
confidence: ConfidenceType.CF_UNCERTAIN,
|
||||||
|
});
|
||||||
|
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
||||||
|
editMetadataValue.editing = true;
|
||||||
|
component.mdValue = editMetadataValue;
|
||||||
|
component.mdField = 'metadata.suggester';
|
||||||
|
component.ngOnInit();
|
||||||
|
fixture.detectChanges();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should render the DsDynamicOneboxComponent', () => {
|
||||||
|
expect(vocabularyService.getVocabularyByMetadataAndCollection).toHaveBeenCalled();
|
||||||
|
expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getModel should return a DynamicOneboxModel', () => {
|
||||||
|
const model = component.getModel();
|
||||||
|
|
||||||
|
expect(model instanceof DynamicOneboxModel).toBe(true);
|
||||||
|
expect(model.vocabularyOptions.name).toBe(mockVocabularySuggester.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('authority key edition', () => {
|
||||||
|
|
||||||
|
it('should update confidence to CF_NOVALUE when authority is cleared', () => {
|
||||||
|
component.mdValue.newValue.authority = '';
|
||||||
|
|
||||||
|
component.onChangeAuthorityKey();
|
||||||
|
|
||||||
|
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_NOVALUE);
|
||||||
|
expect(component.confirm.emit).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update confidence to CF_ACCEPTED when authority key is edited', () => {
|
||||||
|
component.mdValue.newValue.authority = 'newAuthority';
|
||||||
|
component.mdValue.originalValue.authority = 'oldAuthority';
|
||||||
|
|
||||||
|
component.onChangeAuthorityKey();
|
||||||
|
|
||||||
|
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED);
|
||||||
|
expect(component.confirm.emit).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not update confidence when authority key remains the same', () => {
|
||||||
|
component.mdValue.newValue.authority = 'sameAuthority';
|
||||||
|
component.mdValue.originalValue.authority = 'sameAuthority';
|
||||||
|
|
||||||
|
component.onChangeAuthorityKey();
|
||||||
|
|
||||||
|
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNCERTAIN);
|
||||||
|
expect(component.confirm.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onChangeEditingAuthorityStatus with true when clicking the lock button', () => {
|
||||||
|
spyOn(component, 'onChangeEditingAuthorityStatus');
|
||||||
|
const lockButton = fixture.nativeElement.querySelector('#metadata-confirm-btn');
|
||||||
|
|
||||||
|
lockButton.click();
|
||||||
|
|
||||||
|
expect(component.onChangeEditingAuthorityStatus).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable the input when editingAuthority is false', (done) => {
|
||||||
|
component.editingAuthority = false;
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.whenStable().then(() => {
|
||||||
|
const inputElement = fixture.nativeElement.querySelector('input[data-test="authority-input"]');
|
||||||
|
expect(inputElement.disabled).toBeTruthy();
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable the input when editingAuthority is true', (done) => {
|
||||||
|
component.editingAuthority = true;
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.whenStable().then(() => {
|
||||||
|
const inputElement = fixture.nativeElement.querySelector('input[data-test="authority-input"]');
|
||||||
|
expect(inputElement.disabled).toBeFalsy();
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update mdValue.newValue properties when authority is present', () => {
|
||||||
|
const event = {
|
||||||
|
value: 'Some value',
|
||||||
|
authority: 'Some authority',
|
||||||
|
};
|
||||||
|
|
||||||
|
component.onChangeAuthorityField(event);
|
||||||
|
|
||||||
|
expect(component.mdValue.newValue.value).toBe(event.value);
|
||||||
|
expect(component.mdValue.newValue.authority).toBe(event.authority);
|
||||||
|
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED);
|
||||||
|
expect(component.confirm.emit).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update mdValue.newValue properties when authority is not present', () => {
|
||||||
|
const event = {
|
||||||
|
value: 'Some value',
|
||||||
|
authority: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
component.onChangeAuthorityField(event);
|
||||||
|
|
||||||
|
expect(component.mdValue.newValue.value).toBe(event.value);
|
||||||
|
expect(component.mdValue.newValue.authority).toBeNull();
|
||||||
|
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNSET);
|
||||||
|
expect(component.confirm.emit).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,331 @@
|
|||||||
|
import {
|
||||||
|
AsyncPipe,
|
||||||
|
NgClass,
|
||||||
|
} from '@angular/common';
|
||||||
|
import {
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
OnChanges,
|
||||||
|
OnInit,
|
||||||
|
SimpleChanges,
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
FormsModule,
|
||||||
|
UntypedFormControl,
|
||||||
|
UntypedFormGroup,
|
||||||
|
} from '@angular/forms';
|
||||||
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import {
|
||||||
|
TranslateModule,
|
||||||
|
TranslateService,
|
||||||
|
} from '@ngx-translate/core';
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
Observable,
|
||||||
|
of as observableOf,
|
||||||
|
} from 'rxjs';
|
||||||
|
import {
|
||||||
|
map,
|
||||||
|
switchMap,
|
||||||
|
take,
|
||||||
|
tap,
|
||||||
|
} from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { ItemDataService } from '../../../../core/data/item-data.service';
|
||||||
|
import { RegistryService } from '../../../../core/registry/registry.service';
|
||||||
|
import { ConfidenceType } from '../../../../core/shared/confidence-type';
|
||||||
|
import {
|
||||||
|
getFirstCompletedRemoteData,
|
||||||
|
metadataFieldsToString,
|
||||||
|
} from '../../../../core/shared/operators';
|
||||||
|
import { Vocabulary } from '../../../../core/submission/vocabularies/models/vocabulary.model';
|
||||||
|
import { VocabularyOptions } from '../../../../core/submission/vocabularies/models/vocabulary-options.model';
|
||||||
|
import { isNotEmpty } from '../../../../shared/empty.util';
|
||||||
|
import { DsDynamicOneboxComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component';
|
||||||
|
import {
|
||||||
|
DsDynamicOneboxModelConfig,
|
||||||
|
DynamicOneboxModel,
|
||||||
|
} from '../../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model';
|
||||||
|
import { DsDynamicScrollableDropdownComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component';
|
||||||
|
import {
|
||||||
|
DynamicScrollableDropdownModel,
|
||||||
|
DynamicScrollableDropdownModelConfig,
|
||||||
|
} from '../../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model';
|
||||||
|
import { FormFieldMetadataValueObject } from '../../../../shared/form/builder/models/form-field-metadata-value.model';
|
||||||
|
import { AuthorityConfidenceStateDirective } from '../../../../shared/form/directives/authority-confidence-state.directive';
|
||||||
|
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||||
|
import { DebounceDirective } from '../../../../shared/utils/debounce.directive';
|
||||||
|
import { followLink } from '../../../../shared/utils/follow-link-config.model';
|
||||||
|
import { AbstractDsoEditMetadataValueFieldComponent } from '../abstract-dso-edit-metadata-value-field.component';
|
||||||
|
import { DsoEditMetadataFieldService } from '../dso-edit-metadata-field.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The component used to gather input for authority controlled metadata fields
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-dso-edit-metadata-authority-field',
|
||||||
|
templateUrl: './dso-edit-metadata-authority-field.component.html',
|
||||||
|
styleUrls: ['./dso-edit-metadata-authority-field.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
DsDynamicScrollableDropdownComponent,
|
||||||
|
DsDynamicOneboxComponent,
|
||||||
|
AuthorityConfidenceStateDirective,
|
||||||
|
NgbTooltipModule,
|
||||||
|
AsyncPipe,
|
||||||
|
TranslateModule,
|
||||||
|
FormsModule,
|
||||||
|
NgClass,
|
||||||
|
DebounceDirective,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DsoEditMetadataAuthorityFieldComponent extends AbstractDsoEditMetadataValueFieldComponent implements OnInit, OnChanges {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the authority field is currently being edited
|
||||||
|
*/
|
||||||
|
public editingAuthority = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the free-text editing is enabled when scrollable dropdown or hierarchical vocabulary is used
|
||||||
|
*/
|
||||||
|
public enabledFreeTextEditing = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field group used by authority field
|
||||||
|
*/
|
||||||
|
group = new UntypedFormGroup({ authorityField: new UntypedFormControl() });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model to use for editing authorities values
|
||||||
|
*/
|
||||||
|
private model$: BehaviorSubject<DynamicOneboxModel | DynamicScrollableDropdownModel> = new BehaviorSubject(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable with information about the authority vocabulary used
|
||||||
|
*/
|
||||||
|
private vocabulary$: Observable<Vocabulary>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observables with information about the authority vocabulary type used
|
||||||
|
*/
|
||||||
|
isAuthorityControlled$: Observable<boolean>;
|
||||||
|
isHierarchicalVocabulary$: Observable<boolean>;
|
||||||
|
isScrollableVocabulary$: Observable<boolean>;
|
||||||
|
isSuggesterVocabulary$: Observable<boolean>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected cdr: ChangeDetectorRef,
|
||||||
|
protected dsoEditMetadataFieldService: DsoEditMetadataFieldService,
|
||||||
|
protected itemService: ItemDataService,
|
||||||
|
protected notificationsService: NotificationsService,
|
||||||
|
protected registryService: RegistryService,
|
||||||
|
protected translate: TranslateService,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.initAuthorityProperties();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise potential properties of a authority controlled metadata field
|
||||||
|
*/
|
||||||
|
initAuthorityProperties(): void {
|
||||||
|
this.vocabulary$ = this.dsoEditMetadataFieldService.findDsoFieldVocabulary(this.dso, this.mdField);
|
||||||
|
|
||||||
|
this.isAuthorityControlled$ = this.vocabulary$.pipe(
|
||||||
|
// Create the model used by the authority fields to ensure its existence when the field is initialized
|
||||||
|
tap((v: Vocabulary) => this.model$.next(this.createModel(v))),
|
||||||
|
map((result: Vocabulary) => isNotEmpty(result)),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.isHierarchicalVocabulary$ = this.vocabulary$.pipe(
|
||||||
|
map((result: Vocabulary) => isNotEmpty(result) && result.hierarchical),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.isScrollableVocabulary$ = this.vocabulary$.pipe(
|
||||||
|
map((result: Vocabulary) => isNotEmpty(result) && result.scrollable),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.isSuggesterVocabulary$ = this.vocabulary$.pipe(
|
||||||
|
map((result: Vocabulary) => isNotEmpty(result) && !result.hierarchical && !result.scrollable),
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model based on the
|
||||||
|
* vocabulary used.
|
||||||
|
*/
|
||||||
|
private createModel(vocabulary: Vocabulary): DynamicOneboxModel | DynamicScrollableDropdownModel {
|
||||||
|
if (isNotEmpty(vocabulary)) {
|
||||||
|
let formFieldValue: FormFieldMetadataValueObject | string;
|
||||||
|
if (isNotEmpty(this.mdValue.newValue.value)) {
|
||||||
|
formFieldValue = new FormFieldMetadataValueObject();
|
||||||
|
formFieldValue.value = this.mdValue.newValue.value;
|
||||||
|
formFieldValue.display = this.mdValue.newValue.value;
|
||||||
|
if (this.mdValue.newValue.authority) {
|
||||||
|
formFieldValue.authority = this.mdValue.newValue.authority;
|
||||||
|
formFieldValue.confidence = this.mdValue.newValue.confidence;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formFieldValue = this.mdValue.newValue.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vocabularyOptions = vocabulary ? {
|
||||||
|
closed: false,
|
||||||
|
name: vocabulary.name,
|
||||||
|
} as VocabularyOptions : null;
|
||||||
|
|
||||||
|
if (!vocabulary.scrollable) {
|
||||||
|
const model: DsDynamicOneboxModelConfig = {
|
||||||
|
id: 'authorityField',
|
||||||
|
label: `${this.dsoType}.edit.metadata.edit.value`,
|
||||||
|
vocabularyOptions: vocabularyOptions,
|
||||||
|
metadataFields: [this.mdField],
|
||||||
|
value: formFieldValue,
|
||||||
|
repeatable: false,
|
||||||
|
submissionId: 'edit-metadata',
|
||||||
|
hasSelectableMetadata: false,
|
||||||
|
};
|
||||||
|
return new DynamicOneboxModel(model);
|
||||||
|
} else {
|
||||||
|
const model: DynamicScrollableDropdownModelConfig = {
|
||||||
|
id: 'authorityField',
|
||||||
|
label: `${this.dsoType}.edit.metadata.edit.value`,
|
||||||
|
placeholder: `${this.dsoType}.edit.metadata.edit.value`,
|
||||||
|
vocabularyOptions: vocabularyOptions,
|
||||||
|
metadataFields: [this.mdField],
|
||||||
|
value: formFieldValue,
|
||||||
|
repeatable: false,
|
||||||
|
submissionId: 'edit-metadata',
|
||||||
|
hasSelectableMetadata: false,
|
||||||
|
maxOptions: 10,
|
||||||
|
};
|
||||||
|
return new DynamicScrollableDropdownModel(model);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change callback for the component. Check if the mdField has changed to retrieve whether it is metadata
|
||||||
|
* that uses a controlled vocabulary and update the related properties
|
||||||
|
*
|
||||||
|
* @param {SimpleChanges} changes
|
||||||
|
*/
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (isNotEmpty(changes.mdField) && !changes.mdField.firstChange) {
|
||||||
|
if (isNotEmpty(changes.mdField.currentValue)) {
|
||||||
|
if (isNotEmpty(changes.mdField.previousValue) &&
|
||||||
|
changes.mdField.previousValue !== changes.mdField.currentValue) {
|
||||||
|
// Clear authority value in case it has been assigned with the previous metadataField used
|
||||||
|
this.mdValue.newValue.authority = null;
|
||||||
|
this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only ask if the current mdField have a period character to reduce request
|
||||||
|
if (changes.mdField.currentValue.includes('.')) {
|
||||||
|
this.validateMetadataField().subscribe((isValid: boolean) => {
|
||||||
|
if (isValid) {
|
||||||
|
this.initAuthorityProperties();
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the metadata field to check if it exists on the server and return an observable boolean for success/error
|
||||||
|
*/
|
||||||
|
validateMetadataField(): Observable<boolean> {
|
||||||
|
return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
switchMap((rd) => {
|
||||||
|
if (rd.hasSucceeded) {
|
||||||
|
return observableOf(rd).pipe(
|
||||||
|
metadataFieldsToString(),
|
||||||
|
take(1),
|
||||||
|
map((fields: string[]) => fields.indexOf(this.mdField) > -1),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage);
|
||||||
|
return [false];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the change of authority field value updating the authority key and confidence as necessary
|
||||||
|
*/
|
||||||
|
onChangeAuthorityField(event): void {
|
||||||
|
if (event) {
|
||||||
|
this.mdValue.newValue.value = event.value;
|
||||||
|
if (event.authority) {
|
||||||
|
this.mdValue.newValue.authority = event.authority;
|
||||||
|
this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED;
|
||||||
|
} else {
|
||||||
|
this.mdValue.newValue.authority = null;
|
||||||
|
this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET;
|
||||||
|
}
|
||||||
|
this.confirm.emit(false);
|
||||||
|
} else {
|
||||||
|
// The event is undefined when the user clears the selection in scrollable dropdown
|
||||||
|
this.mdValue.newValue.value = '';
|
||||||
|
this.mdValue.newValue.authority = null;
|
||||||
|
this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET;
|
||||||
|
this.confirm.emit(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model used
|
||||||
|
* for the authority field
|
||||||
|
*/
|
||||||
|
getModel(): DynamicOneboxModel | DynamicScrollableDropdownModel {
|
||||||
|
return this.model$.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the status of the editingAuthority property
|
||||||
|
* @param status
|
||||||
|
*/
|
||||||
|
onChangeEditingAuthorityStatus(status: boolean) {
|
||||||
|
this.editingAuthority = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes the change in authority value, updating the confidence as necessary.
|
||||||
|
* If the authority key is cleared, the confidence is set to {@link ConfidenceType.CF_NOVALUE}.
|
||||||
|
* If the authority key is edited and differs from the original, the confidence is set to {@link ConfidenceType.CF_ACCEPTED}.
|
||||||
|
*/
|
||||||
|
onChangeAuthorityKey() {
|
||||||
|
if (this.mdValue.newValue.authority === '') {
|
||||||
|
this.mdValue.newValue.confidence = ConfidenceType.CF_NOVALUE;
|
||||||
|
this.confirm.emit(false);
|
||||||
|
} else if (this.mdValue.newValue.authority !== this.mdValue.originalValue.authority) {
|
||||||
|
this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED;
|
||||||
|
this.confirm.emit(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the free-text editing mode
|
||||||
|
*/
|
||||||
|
toggleFreeTextEdition() {
|
||||||
|
if (this.enabledFreeTextEditing) {
|
||||||
|
if (this.getModel().value !== this.mdValue.newValue.value) {
|
||||||
|
// Reload the model to adapt it to the new possible value modified during free text editing
|
||||||
|
this.initAuthorityProperties();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.enabledFreeTextEditing = !this.enabledFreeTextEditing;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,8 @@
|
|||||||
|
<select class="form-select" [(ngModel)]="mdValue?.newValue.value" (ngModelChange)="confirm.emit(false)"
|
||||||
|
[attr.aria-label]="(dsoType + '.edit.metadata.edit.value') | translate">
|
||||||
|
@for (entity of (entities$ | async); track entity.label) {
|
||||||
|
<option [value]="entity.label === 'none' ? undefined : entity.label">
|
||||||
|
{{ entity.label }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
} from '@angular/core/testing';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { EntityTypeDataService } from '../../../../core/data/entity-type-data.service';
|
||||||
|
import { EntityTypeDataServiceStub } from '../../../../shared/testing/entity-type-data.service.stub';
|
||||||
|
import { DsoEditMetadataEntityFieldComponent } from './dso-edit-metadata-entity-field.component';
|
||||||
|
|
||||||
|
describe('DsoEditMetadataEntityFieldComponent', () => {
|
||||||
|
let component: DsoEditMetadataEntityFieldComponent;
|
||||||
|
let fixture: ComponentFixture<DsoEditMetadataEntityFieldComponent>;
|
||||||
|
|
||||||
|
let entityTypeService: EntityTypeDataServiceStub;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
entityTypeService = new EntityTypeDataServiceStub();
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
DsoEditMetadataEntityFieldComponent,
|
||||||
|
TranslateModule.forRoot(),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: EntityTypeDataService, useValue: entityTypeService },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(DsoEditMetadataEntityFieldComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,48 @@
|
|||||||
|
import { AsyncPipe } from '@angular/common';
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { EntityTypeDataService } from '../../../../core/data/entity-type-data.service';
|
||||||
|
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model';
|
||||||
|
import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators';
|
||||||
|
import { AbstractDsoEditMetadataValueFieldComponent } from '../abstract-dso-edit-metadata-value-field.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The component used to gather input for entity-type metadata fields
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-dso-edit-metadata-entity-field',
|
||||||
|
templateUrl: './dso-edit-metadata-entity-field.component.html',
|
||||||
|
styleUrls: ['./dso-edit-metadata-entity-field.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
|
FormsModule,
|
||||||
|
TranslateModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DsoEditMetadataEntityFieldComponent extends AbstractDsoEditMetadataValueFieldComponent implements OnInit {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of all the existing entity types
|
||||||
|
*/
|
||||||
|
entities$: Observable<ItemType[]>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected entityTypeService: EntityTypeDataService,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.entities$ = this.entityTypeService.findAll({ elementsPerPage: 100, currentPage: 1 }).pipe(
|
||||||
|
getFirstSucceededRemoteListPayload(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* The edit metadata field tab types
|
||||||
|
*/
|
||||||
|
export enum EditMetadataValueFieldType {
|
||||||
|
PLAIN_TEXT = 'PLAIN_TEXT',
|
||||||
|
ENTITY_TYPE = 'ENTITY_TYPE',
|
||||||
|
AUTHORITY = 'AUTHORITY',
|
||||||
|
}
|
@@ -0,0 +1,31 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||||
|
import { VocabularyService } from '../../../core/submission/vocabularies/vocabulary.service';
|
||||||
|
import { ItemDataServiceStub } from '../../../shared/testing/item-data.service.stub';
|
||||||
|
import { VocabularyServiceStub } from '../../../shared/testing/vocabulary-service.stub';
|
||||||
|
import { DsoEditMetadataFieldService } from './dso-edit-metadata-field.service';
|
||||||
|
|
||||||
|
describe('DsoEditMetadataFieldService', () => {
|
||||||
|
let service: DsoEditMetadataFieldService;
|
||||||
|
|
||||||
|
let itemService: ItemDataServiceStub;
|
||||||
|
let vocabularyService: VocabularyServiceStub;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
itemService = new ItemDataServiceStub();
|
||||||
|
vocabularyService = new VocabularyServiceStub();
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{ provide: ItemDataService, useValue: itemService },
|
||||||
|
{ provide: VocabularyService, useValue: vocabularyService },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
service = TestBed.inject(DsoEditMetadataFieldService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,56 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
Observable,
|
||||||
|
of as observableOf,
|
||||||
|
} from 'rxjs';
|
||||||
|
import { switchMap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||||
|
import { Collection } from '../../../core/shared/collection.model';
|
||||||
|
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
||||||
|
import { Item } from '../../../core/shared/item.model';
|
||||||
|
import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators';
|
||||||
|
import { Vocabulary } from '../../../core/submission/vocabularies/models/vocabulary.model';
|
||||||
|
import { VocabularyService } from '../../../core/submission/vocabularies/vocabulary.service';
|
||||||
|
import { isNotEmpty } from '../../../shared/empty.util';
|
||||||
|
import { followLink } from '../../../shared/utils/follow-link-config.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A service containing all the common logic for the components generated by the
|
||||||
|
* {@link DsoEditMetadataValueFieldLoaderComponent}.
|
||||||
|
*/
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class DsoEditMetadataFieldService {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected itemService: ItemDataService,
|
||||||
|
protected vocabularyService: VocabularyService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the vocabulary of the given {@link mdField} for the given item.
|
||||||
|
*
|
||||||
|
* @param dso The item
|
||||||
|
* @param mdField The metadata field
|
||||||
|
*/
|
||||||
|
findDsoFieldVocabulary(dso: DSpaceObject, mdField: string): Observable<Vocabulary> {
|
||||||
|
if (isNotEmpty(mdField)) {
|
||||||
|
const owningCollection$: Observable<Collection> = this.itemService.findByHref(dso._links.self.href, true, true, followLink('owningCollection')).pipe(
|
||||||
|
getFirstSucceededRemoteDataPayload(),
|
||||||
|
switchMap((item: Item) => item.owningCollection),
|
||||||
|
getFirstSucceededRemoteDataPayload(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return owningCollection$.pipe(
|
||||||
|
switchMap((c: Collection) => this.vocabularyService.getVocabularyByMetadataAndCollection(mdField, c.uuid).pipe(
|
||||||
|
getFirstSucceededRemoteDataPayload(),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return observableOf(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,7 @@
|
|||||||
|
<textarea [(ngModel)]="mdValue?.newValue.value"
|
||||||
|
[attr.aria-label]="(dsoType + '.edit.metadata.edit.value') | translate"
|
||||||
|
[dsDebounce]="300"
|
||||||
|
(onDebounce)="confirm.emit(false)"
|
||||||
|
class="form-control"
|
||||||
|
rows="5">
|
||||||
|
</textarea>
|
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
} from '@angular/core/testing';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { DsoEditMetadataTextFieldComponent } from './dso-edit-metadata-text-field.component';
|
||||||
|
|
||||||
|
describe('DsoEditMetadataTextFieldComponent', () => {
|
||||||
|
let component: DsoEditMetadataTextFieldComponent;
|
||||||
|
let fixture: ComponentFixture<DsoEditMetadataTextFieldComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
DsoEditMetadataTextFieldComponent,
|
||||||
|
TranslateModule.forRoot(),
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(DsoEditMetadataTextFieldComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,23 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { DebounceDirective } from '../../../../shared/utils/debounce.directive';
|
||||||
|
import { AbstractDsoEditMetadataValueFieldComponent } from '../abstract-dso-edit-metadata-value-field.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The component used to gather input for plain-text metadata fields
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-dso-edit-metadata-text-field',
|
||||||
|
templateUrl: './dso-edit-metadata-text-field.component.html',
|
||||||
|
styleUrls: ['./dso-edit-metadata-text-field.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
DebounceDirective,
|
||||||
|
FormsModule,
|
||||||
|
TranslateModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DsoEditMetadataTextFieldComponent extends AbstractDsoEditMetadataValueFieldComponent {
|
||||||
|
}
|
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
import { Context } from '../../../../core/shared/context.model';
|
||||||
|
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
|
||||||
|
import { GenericConstructor } from '../../../../core/shared/generic-constructor';
|
||||||
|
import { AbstractComponentLoaderComponent } from '../../../../shared/abstract-component-loader/abstract-component-loader.component';
|
||||||
|
import { DynamicComponentLoaderDirective } from '../../../../shared/abstract-component-loader/dynamic-component-loader.directive';
|
||||||
|
import { DsoEditMetadataValue } from '../../dso-edit-metadata-form';
|
||||||
|
import { EditMetadataValueFieldType } from '../dso-edit-metadata-field-type.enum';
|
||||||
|
import { getDsoEditMetadataValueFieldComponent } from './dso-edit-metadata-value-field.decorator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component responsible for dynamically loading and rendering the appropriate edit metadata value field components
|
||||||
|
* based on the type of the metadata field ({@link EditMetadataValueFieldType}) and the place where it's used
|
||||||
|
* ({@link Context}).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-dso-edit-metadata-value-field-loader',
|
||||||
|
templateUrl: '../../../../shared/abstract-component-loader/abstract-component-loader.component.html',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
DynamicComponentLoaderDirective,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DsoEditMetadataValueFieldLoaderComponent extends AbstractComponentLoaderComponent<Component> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The optional context
|
||||||
|
*/
|
||||||
|
@Input() context: Context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link DSpaceObject}
|
||||||
|
*/
|
||||||
|
@Input() dso: DSpaceObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of the DSO, used to determines i18n messages
|
||||||
|
*/
|
||||||
|
@Input() dsoType: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of the field
|
||||||
|
*/
|
||||||
|
@Input() type: EditMetadataValueFieldType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata field
|
||||||
|
*/
|
||||||
|
@Input() mdField: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editable metadata value to show
|
||||||
|
*/
|
||||||
|
@Input() mdValue: DsoEditMetadataValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits when the user clicked confirm
|
||||||
|
*/
|
||||||
|
@Output() confirm: EventEmitter<boolean> = new EventEmitter();
|
||||||
|
|
||||||
|
protected inputNamesDependentForComponent: (keyof this & string)[] = [
|
||||||
|
'context',
|
||||||
|
'type',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected inputNames: (keyof this & string)[] = [
|
||||||
|
'context',
|
||||||
|
'dso',
|
||||||
|
'dsoType',
|
||||||
|
'type',
|
||||||
|
'mdField',
|
||||||
|
'mdValue',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected outputNames: (keyof this & string)[] = [
|
||||||
|
'confirm',
|
||||||
|
];
|
||||||
|
|
||||||
|
public getComponent(): GenericConstructor<Component> {
|
||||||
|
return getDsoEditMetadataValueFieldComponent(this.type, this.context, this.themeService.getThemeName());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,59 @@
|
|||||||
|
import { Context } from '../../../../core/shared/context.model';
|
||||||
|
import { hasValue } from '../../../../shared/empty.util';
|
||||||
|
import {
|
||||||
|
DEFAULT_CONTEXT,
|
||||||
|
DEFAULT_THEME,
|
||||||
|
resolveTheme,
|
||||||
|
} from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
|
||||||
|
import { DsoEditMetadataAuthorityFieldComponent } from '../dso-edit-metadata-authority-field/dso-edit-metadata-authority-field.component';
|
||||||
|
import { DsoEditMetadataEntityFieldComponent } from '../dso-edit-metadata-entity-field/dso-edit-metadata-entity-field.component';
|
||||||
|
import { EditMetadataValueFieldType } from '../dso-edit-metadata-field-type.enum';
|
||||||
|
import { DsoEditMetadataTextFieldComponent } from '../dso-edit-metadata-text-field/dso-edit-metadata-text-field.component';
|
||||||
|
|
||||||
|
export type MetadataValueFieldComponent =
|
||||||
|
typeof DsoEditMetadataTextFieldComponent |
|
||||||
|
typeof DsoEditMetadataEntityFieldComponent |
|
||||||
|
typeof DsoEditMetadataAuthorityFieldComponent;
|
||||||
|
|
||||||
|
export const map = new Map<EditMetadataValueFieldType, Map<Context, Map<string, MetadataValueFieldComponent>>>([
|
||||||
|
[EditMetadataValueFieldType.PLAIN_TEXT, new Map([
|
||||||
|
[DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, DsoEditMetadataTextFieldComponent]])],
|
||||||
|
])],
|
||||||
|
[EditMetadataValueFieldType.ENTITY_TYPE, new Map([
|
||||||
|
[DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, DsoEditMetadataEntityFieldComponent]])],
|
||||||
|
])],
|
||||||
|
[EditMetadataValueFieldType.AUTHORITY, new Map([
|
||||||
|
[DEFAULT_CONTEXT, new Map([[DEFAULT_THEME, DsoEditMetadataAuthorityFieldComponent]])],
|
||||||
|
])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const DEFAULT_EDIT_METADATA_FIELD_TYPE = EditMetadataValueFieldType.PLAIN_TEXT;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter to retrieve a matching component by entity type, metadata representation and context
|
||||||
|
*
|
||||||
|
* @param type The edit metadata field type
|
||||||
|
* @param context The context to match
|
||||||
|
* @param theme the theme to match
|
||||||
|
*/
|
||||||
|
export function getDsoEditMetadataValueFieldComponent(type: EditMetadataValueFieldType, context: Context = DEFAULT_CONTEXT, theme = DEFAULT_THEME) {
|
||||||
|
if (type) {
|
||||||
|
const mapForEntity = map.get(type);
|
||||||
|
if (hasValue(mapForEntity)) {
|
||||||
|
const contextMap = mapForEntity.get(context);
|
||||||
|
if (hasValue(contextMap)) {
|
||||||
|
const match = resolveTheme(contextMap, theme);
|
||||||
|
if (hasValue(match)) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
if (hasValue(contextMap.get(DEFAULT_THEME))) {
|
||||||
|
return contextMap.get(DEFAULT_THEME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasValue(mapForEntity.get(DEFAULT_CONTEXT)) && hasValue(mapForEntity.get(DEFAULT_CONTEXT).get(DEFAULT_THEME))) {
|
||||||
|
return mapForEntity.get(DEFAULT_CONTEXT).get(DEFAULT_THEME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map.get(DEFAULT_EDIT_METADATA_FIELD_TYPE).get(DEFAULT_CONTEXT).get(DEFAULT_THEME);
|
||||||
|
}
|
@@ -1,37 +1,22 @@
|
|||||||
<div class="d-flex flex-row ds-value-row" *ngVar="metadataService.isVirtual(mdValue.newValue) as isVirtual" role="row"
|
@let isVirtual = metadataService.isVirtual(mdValue.newValue);
|
||||||
|
<div class="d-flex flex-row ds-value-row" role="row"
|
||||||
cdkDrag (cdkDragStarted)="dragging.emit(true)" (cdkDragEnded)="dragging.emit(false)"
|
cdkDrag (cdkDragStarted)="dragging.emit(true)" (cdkDragEnded)="dragging.emit(false)"
|
||||||
[ngClass]="{ 'ds-warning': mdValue.reordered || mdValue.change === DsoEditMetadataChangeTypeEnum.UPDATE, 'ds-danger': mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE, 'ds-success': mdValue.change === DsoEditMetadataChangeTypeEnum.ADD, 'h-100': isOnlyValue }">
|
[ngClass]="{ 'ds-warning': mdValue.reordered || mdValue.change === DsoEditMetadataChangeTypeEnum.UPDATE, 'ds-danger': mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE, 'ds-success': mdValue.change === DsoEditMetadataChangeTypeEnum.ADD, 'h-100': isOnlyValue }">
|
||||||
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex flex-column" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell">
|
@let mdRepresentation = (mdRepresentation$ | async);
|
||||||
|
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex flex-column" role="cell">
|
||||||
@if (!mdValue.editing && !mdRepresentation) {
|
@if (!mdValue.editing && !mdRepresentation) {
|
||||||
<div class="dont-break-out preserve-line-breaks">{{ mdValue.newValue.value }}</div>
|
<div class="dont-break-out preserve-line-breaks">{{ mdValue.newValue.value }}</div>
|
||||||
}
|
}
|
||||||
@if (mdValue.editing && !mdRepresentation && ((isAuthorityControlled() | async) !== true || (enabledFreeTextEditing && (isSuggesterVocabulary() | async) !== true))) {
|
@if (mdValue.editing && !mdRepresentation) {
|
||||||
<textarea class="form-control" rows="5" [(ngModel)]="mdValue.newValue.value"
|
<ds-dso-edit-metadata-value-field-loader [context]="context"
|
||||||
[attr.aria-label]="(dsoType + '.edit.metadata.edit.value') | translate"
|
[dso]="dso"
|
||||||
[dsDebounce]="300" (onDebounce)="confirm.emit(false)"></textarea>
|
[dsoType]="dsoType"
|
||||||
}
|
[mdField]="mdField"
|
||||||
@if (mdValue.editing && (isScrollableVocabulary() | async) && !enabledFreeTextEditing) {
|
[mdValue]="mdValue"
|
||||||
<ds-dynamic-scrollable-dropdown
|
[type]="fieldType$ | async"
|
||||||
[bindId]="mdField"
|
(confirm)="confirm.emit($event)"
|
||||||
[group]="group"
|
class="w-100">
|
||||||
[model]="getModel()"
|
</ds-dso-edit-metadata-value-field-loader>
|
||||||
(change)="onChangeAuthorityField($event)">
|
|
||||||
</ds-dynamic-scrollable-dropdown>
|
|
||||||
}
|
|
||||||
@if (mdValue.editing && (((isHierarchicalVocabulary() | async) && !enabledFreeTextEditing) || (isSuggesterVocabulary() | async))) {
|
|
||||||
<ds-dynamic-onebox
|
|
||||||
[group]="group"
|
|
||||||
[model]="getModel()"
|
|
||||||
(change)="onChangeAuthorityField($event)">
|
|
||||||
</ds-dynamic-onebox>
|
|
||||||
}
|
|
||||||
@if (mdValue.editing && ((isScrollableVocabulary() | async) || (isHierarchicalVocabulary() | async))) {
|
|
||||||
<button class="btn btn-secondary mt-2"
|
|
||||||
[title]="enabledFreeTextEditing ? dsoType + '.edit.metadata.edit.buttons.disable-free-text-editing' : dsoType + '.edit.metadata.edit.buttons.enable-free-text-editing' | translate"
|
|
||||||
(click)="toggleFreeTextEdition()">
|
|
||||||
<i class="fas fa-fw" [ngClass]="enabledFreeTextEditing ? 'fa-lock' : 'fa-unlock'"></i>
|
|
||||||
{{ (enabledFreeTextEditing ? dsoType + '.edit.metadata.edit.buttons.disable-free-text-editing' : dsoType + '.edit.metadata.edit.buttons.enable-free-text-editing') | translate }}
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
@if (!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_UNSET && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_NOVALUE) {
|
@if (!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_UNSET && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_NOVALUE) {
|
||||||
<div>
|
<div>
|
||||||
@@ -46,88 +31,62 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if ( mdValue.editing && (isAuthorityControlled() | async) && (isSuggesterVocabulary() | async)) {
|
|
||||||
<div class="mt-2">
|
|
||||||
<div class="btn-group w-75">
|
|
||||||
<i dsAuthorityConfidenceState
|
|
||||||
class="fas fa-fw p-0 me-1 mt-auto mb-auto"
|
|
||||||
aria-hidden="true"
|
|
||||||
[authorityValue]="mdValue.newValue.confidence"
|
|
||||||
[iconMode]="true"
|
|
||||||
></i>
|
|
||||||
<input class="form-control form-outline" data-test="authority-input" [(ngModel)]="mdValue.newValue.authority" [disabled]="!editingAuthority"
|
|
||||||
[attr.aria-label]="(dsoType + '.edit.metadata.edit.authority.key') | translate"
|
|
||||||
(change)="onChangeAuthorityKey()" />
|
|
||||||
@if (!editingAuthority) {
|
|
||||||
<button class="btn btn-outline-secondary btn-sm ng-star-inserted" id="metadata-confirm-btn"
|
|
||||||
[title]="dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate"
|
|
||||||
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate }}"
|
|
||||||
(click)="onChangeEditingAuthorityStatus(true)">
|
|
||||||
<i class="fas fa-lock fa-fw"></i>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (editingAuthority) {
|
|
||||||
<button class="btn btn-outline-success btn-sm ng-star-inserted" id="metadata-confirm-btn"
|
|
||||||
[title]="dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate"
|
|
||||||
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate }}"
|
|
||||||
(click)="onChangeEditingAuthorityStatus(false)">
|
|
||||||
<i class="fas fa-lock-open fa-fw"></i>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (mdRepresentation) {
|
@if (mdRepresentation) {
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<a class="me-2" target="_blank" [routerLink]="mdRepresentationItemRoute$ | async">{{ mdRepresentationName$ | async }}</a>
|
<a class="me-2" target="_blank"
|
||||||
|
[routerLink]="mdRepresentationItemRoute$ | async">{{ mdRepresentationName$ | async }}</a>
|
||||||
<ds-type-badge [object]="mdRepresentation"></ds-type-badge>
|
<ds-type-badge [object]="mdRepresentation"></ds-type-badge>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="ds-flex-cell ds-lang-cell" role="cell">
|
<div class="ds-flex-cell ds-lang-cell" role="cell">
|
||||||
@if (!mdValue.editing) {
|
|
||||||
<div class="dont-break-out preserve-line-breaks">{{ mdValue.newValue.language }}</div>
|
|
||||||
}
|
|
||||||
@if (mdValue.editing) {
|
@if (mdValue.editing) {
|
||||||
<input class="form-control" type="text" [(ngModel)]="mdValue.newValue.language"
|
<input class="form-control" type="text" [(ngModel)]="mdValue.newValue.language"
|
||||||
[attr.aria-label]="(dsoType + '.edit.metadata.edit.language') | translate"
|
[attr.aria-label]="(dsoType + '.edit.metadata.edit.language') | translate"
|
||||||
[dsDebounce]="300" (onDebounce)="confirm.emit(false)"/>
|
[dsDebounce]="300" (onDebounce)="confirm.emit(false)"/>
|
||||||
|
} @else {
|
||||||
|
<div class="dont-break-out preserve-line-breaks">{{ mdValue.newValue.language }}</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center ds-flex-cell ds-edit-cell" role="cell">
|
<div class="text-center ds-flex-cell ds-edit-cell" role="cell">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<div class="edit-field">
|
<div class="edit-field">
|
||||||
<div class="btn-group edit-buttons" [ngbTooltip]="isVirtual ? (dsoType + '.edit.metadata.edit.buttons.virtual' | translate) : null">
|
<div class="btn-group edit-buttons"
|
||||||
@if (!mdValue.editing) {
|
[ngbTooltip]="isVirtual ? (dsoType + '.edit.metadata.edit.buttons.virtual' | translate) : null">
|
||||||
<button class="btn btn-outline-primary btn-sm ng-star-inserted" data-test="metadata-edit-btn"
|
@let saving = (saving$ | async);
|
||||||
[title]="dsoType + '.edit.metadata.edit.buttons.edit' | translate"
|
|
||||||
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.edit' | translate }}"
|
|
||||||
[dsBtnDisabled]="isVirtual || mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE || (saving$ | async)" (click)="edit.emit()">
|
|
||||||
<i class="fas fa-edit fa-fw"></i>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (mdValue.editing) {
|
@if (mdValue.editing) {
|
||||||
<button class="btn btn-outline-success btn-sm ng-star-inserted" data-test="metadata-confirm-btn"
|
<button class="btn btn-outline-success btn-sm ng-star-inserted" data-test="metadata-confirm-btn"
|
||||||
[title]="dsoType + '.edit.metadata.edit.buttons.confirm' | translate"
|
[title]="dsoType + '.edit.metadata.edit.buttons.confirm' | translate"
|
||||||
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.confirm' | translate }}"
|
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.confirm' | translate }}"
|
||||||
[dsBtnDisabled]="isVirtual || (saving$ | async)" (click)="confirm.emit(true)">
|
[dsBtnDisabled]="isVirtual || saving" (click)="confirm.emit(true)">
|
||||||
<i class="fas fa-check fa-fw"></i>
|
<i class="fas fa-check fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button class="btn btn-outline-primary btn-sm ng-star-inserted" data-test="metadata-edit-btn"
|
||||||
|
[title]="dsoType + '.edit.metadata.edit.buttons.edit' | translate"
|
||||||
|
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.edit' | translate }}"
|
||||||
|
[dsBtnDisabled]="isVirtual || mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE || saving"
|
||||||
|
(click)="edit.emit()">
|
||||||
|
<i class="fas fa-edit fa-fw"></i>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
<button class="btn btn-outline-danger btn-sm" data-test="metadata-remove-btn"
|
<button class="btn btn-outline-danger btn-sm" data-test="metadata-remove-btn"
|
||||||
[title]="dsoType + '.edit.metadata.edit.buttons.remove' | translate"
|
[title]="dsoType + '.edit.metadata.edit.buttons.remove' | translate"
|
||||||
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.remove' | translate }}"
|
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.remove' | translate }}"
|
||||||
[dsBtnDisabled]="isVirtual || (mdValue.change && mdValue.change !== DsoEditMetadataChangeTypeEnum.ADD) || mdValue.editing || (saving$ | async)" (click)="remove.emit()">
|
[dsBtnDisabled]="isVirtual || (mdValue.change && mdValue.change !== DsoEditMetadataChangeTypeEnum.ADD) || mdValue.editing || saving"
|
||||||
|
(click)="remove.emit()">
|
||||||
<i class="fas fa-trash-alt fa-fw"></i>
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-warning btn-sm" data-test="metadata-undo-btn"
|
<button class="btn btn-outline-warning btn-sm" data-test="metadata-undo-btn"
|
||||||
[title]="dsoType + '.edit.metadata.edit.buttons.undo' | translate"
|
[title]="dsoType + '.edit.metadata.edit.buttons.undo' | translate"
|
||||||
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.undo' | translate }}"
|
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.undo' | translate }}"
|
||||||
[dsBtnDisabled]="isVirtual || (!mdValue.change && mdValue.reordered) || (!mdValue.change && !mdValue.editing) || (saving$ | async)" (click)="undo.emit()">
|
[dsBtnDisabled]="isVirtual || (!mdValue.change && mdValue.reordered) || (!mdValue.change && !mdValue.editing) || saving"
|
||||||
|
(click)="undo.emit()">
|
||||||
<i class="fas fa-undo-alt fa-fw"></i>
|
<i class="fas fa-undo-alt fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-secondary ds-drag-handle btn-sm" data-test="metadata-drag-btn" *ngVar="(isOnlyValue || (saving$ | async)) as disabled"
|
<button class="btn btn-outline-secondary ds-drag-handle btn-sm" data-test="metadata-drag-btn"
|
||||||
cdkDragHandle [cdkDragHandleDisabled]="disabled" [ngClass]="{'disabled': disabled}" [dsBtnDisabled]="disabled"
|
cdkDragHandle [cdkDragHandleDisabled]="isOnlyValue || saving"
|
||||||
|
[class.disabled]="isOnlyValue || saving" [dsBtnDisabled]="isOnlyValue || saving"
|
||||||
[title]="dsoType + '.edit.metadata.edit.buttons.drag' | translate"
|
[title]="dsoType + '.edit.metadata.edit.buttons.drag' | translate"
|
||||||
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}">
|
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}">
|
||||||
<i class="fas fa-grip-vertical fa-fw"></i>
|
<i class="fas fa-grip-vertical fa-fw"></i>
|
||||||
|
@@ -8,42 +8,27 @@ import {
|
|||||||
waitForAsync,
|
waitForAsync,
|
||||||
} from '@angular/core/testing';
|
} from '@angular/core/testing';
|
||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { RouterTestingModule } from '@angular/router/testing';
|
import { RouterModule } from '@angular/router';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { MetadataField } from 'src/app/core/metadata/metadata-field.model';
|
|
||||||
import { MetadataSchema } from 'src/app/core/metadata/metadata-schema.model';
|
|
||||||
import { RegistryService } from 'src/app/core/registry/registry.service';
|
|
||||||
import { ConfidenceType } from 'src/app/core/shared/confidence-type';
|
|
||||||
import { Vocabulary } from 'src/app/core/submission/vocabularies/models/vocabulary.model';
|
|
||||||
import { VocabularyService } from 'src/app/core/submission/vocabularies/vocabulary.service';
|
|
||||||
import { DynamicOneboxModel } from 'src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model';
|
|
||||||
import { DynamicScrollableDropdownModel } from 'src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model';
|
|
||||||
import { NotificationsService } from 'src/app/shared/notifications/notifications.service';
|
|
||||||
import { createPaginatedList } from 'src/app/shared/testing/utils.test';
|
|
||||||
import { VocabularyServiceStub } from 'src/app/shared/testing/vocabulary-service.stub';
|
|
||||||
|
|
||||||
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
|
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
|
||||||
import { ItemDataService } from '../../../core/data/item-data.service';
|
|
||||||
import { RelationshipDataService } from '../../../core/data/relationship-data.service';
|
import { RelationshipDataService } from '../../../core/data/relationship-data.service';
|
||||||
import { Collection } from '../../../core/shared/collection.model';
|
|
||||||
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
|
||||||
import { Item } from '../../../core/shared/item.model';
|
|
||||||
import {
|
import {
|
||||||
MetadataValue,
|
MetadataValue,
|
||||||
VIRTUAL_METADATA_PREFIX,
|
VIRTUAL_METADATA_PREFIX,
|
||||||
} from '../../../core/shared/metadata.models';
|
} from '../../../core/shared/metadata.models';
|
||||||
import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
|
import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
|
||||||
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
|
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
|
||||||
import { DsDynamicOneboxComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component';
|
|
||||||
import { DsDynamicScrollableDropdownComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component';
|
|
||||||
import { ThemedTypeBadgeComponent } from '../../../shared/object-collection/shared/badges/type-badge/themed-type-badge.component';
|
import { ThemedTypeBadgeComponent } from '../../../shared/object-collection/shared/badges/type-badge/themed-type-badge.component';
|
||||||
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
import { DsoEditMetadataFieldServiceStub } from '../../../shared/testing/dso-edit-metadata-field.service.stub';
|
||||||
import { VarDirective } from '../../../shared/utils/var.directive';
|
import { VarDirective } from '../../../shared/utils/var.directive';
|
||||||
import {
|
import {
|
||||||
DsoEditMetadataChangeType,
|
DsoEditMetadataChangeType,
|
||||||
DsoEditMetadataValue,
|
DsoEditMetadataValue,
|
||||||
} from '../dso-edit-metadata-form';
|
} from '../dso-edit-metadata-form';
|
||||||
|
import { DsoEditMetadataFieldService } from '../dso-edit-metadata-value-field/dso-edit-metadata-field.service';
|
||||||
|
import { DsoEditMetadataValueFieldLoaderComponent } from '../dso-edit-metadata-value-field/dso-edit-metadata-value-field-loader/dso-edit-metadata-value-field-loader.component';
|
||||||
import { DsoEditMetadataValueComponent } from './dso-edit-metadata-value.component';
|
import { DsoEditMetadataValueComponent } from './dso-edit-metadata-value.component';
|
||||||
|
|
||||||
const EDIT_BTN = 'edit';
|
const EDIT_BTN = 'edit';
|
||||||
@@ -58,97 +43,12 @@ describe('DsoEditMetadataValueComponent', () => {
|
|||||||
|
|
||||||
let relationshipService: RelationshipDataService;
|
let relationshipService: RelationshipDataService;
|
||||||
let dsoNameService: DSONameService;
|
let dsoNameService: DSONameService;
|
||||||
let vocabularyServiceStub: any;
|
let dsoEditMetadataFieldService: DsoEditMetadataFieldServiceStub;
|
||||||
let itemService: ItemDataService;
|
|
||||||
let registryService: RegistryService;
|
|
||||||
let notificationsService: NotificationsService;
|
|
||||||
|
|
||||||
let editMetadataValue: DsoEditMetadataValue;
|
let editMetadataValue: DsoEditMetadataValue;
|
||||||
let metadataValue: MetadataValue;
|
let metadataValue: MetadataValue;
|
||||||
let dso: DSpaceObject;
|
|
||||||
|
|
||||||
const collection = Object.assign(new Collection(), {
|
|
||||||
uuid: 'fake-uuid',
|
|
||||||
});
|
|
||||||
|
|
||||||
const item = Object.assign(new Item(), {
|
|
||||||
_links: {
|
|
||||||
self: { href: 'fake-item-url/item' },
|
|
||||||
},
|
|
||||||
id: 'item',
|
|
||||||
uuid: 'item',
|
|
||||||
owningCollection: createSuccessfulRemoteDataObject$(collection),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockVocabularyScrollable: Vocabulary = {
|
|
||||||
id: 'scrollable',
|
|
||||||
name: 'scrollable',
|
|
||||||
scrollable: true,
|
|
||||||
hierarchical: false,
|
|
||||||
preloadLevel: 0,
|
|
||||||
type: 'vocabulary',
|
|
||||||
_links: {
|
|
||||||
self: {
|
|
||||||
href: 'self',
|
|
||||||
},
|
|
||||||
entries: {
|
|
||||||
href: 'entries',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockVocabularyHierarchical: Vocabulary = {
|
|
||||||
id: 'hierarchical',
|
|
||||||
name: 'hierarchical',
|
|
||||||
scrollable: false,
|
|
||||||
hierarchical: true,
|
|
||||||
preloadLevel: 2,
|
|
||||||
type: 'vocabulary',
|
|
||||||
_links: {
|
|
||||||
self: {
|
|
||||||
href: 'self',
|
|
||||||
},
|
|
||||||
entries: {
|
|
||||||
href: 'entries',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockVocabularySuggester: Vocabulary = {
|
|
||||||
id: 'suggester',
|
|
||||||
name: 'suggester',
|
|
||||||
scrollable: false,
|
|
||||||
hierarchical: false,
|
|
||||||
preloadLevel: 0,
|
|
||||||
type: 'vocabulary',
|
|
||||||
_links: {
|
|
||||||
self: {
|
|
||||||
href: 'self',
|
|
||||||
},
|
|
||||||
entries: {
|
|
||||||
href: 'entries',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let metadataSchema: MetadataSchema;
|
|
||||||
let metadataFields: MetadataField[];
|
|
||||||
|
|
||||||
function initServices(): void {
|
function initServices(): void {
|
||||||
metadataSchema = Object.assign(new MetadataSchema(), {
|
|
||||||
id: 0,
|
|
||||||
prefix: 'metadata',
|
|
||||||
namespace: 'http://example.com/',
|
|
||||||
});
|
|
||||||
metadataFields = [
|
|
||||||
Object.assign(new MetadataField(), {
|
|
||||||
id: 0,
|
|
||||||
element: 'regular',
|
|
||||||
qualifier: null,
|
|
||||||
schema: createSuccessfulRemoteDataObject$(metadataSchema),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
relationshipService = jasmine.createSpyObj('relationshipService', {
|
relationshipService = jasmine.createSpyObj('relationshipService', {
|
||||||
resolveMetadataRepresentation: of(
|
resolveMetadataRepresentation: of(
|
||||||
new ItemMetadataRepresentation(metadataValue),
|
new ItemMetadataRepresentation(metadataValue),
|
||||||
@@ -157,14 +57,7 @@ describe('DsoEditMetadataValueComponent', () => {
|
|||||||
dsoNameService = jasmine.createSpyObj('dsoNameService', {
|
dsoNameService = jasmine.createSpyObj('dsoNameService', {
|
||||||
getName: 'Related Name',
|
getName: 'Related Name',
|
||||||
});
|
});
|
||||||
itemService = jasmine.createSpyObj('itemService', {
|
dsoEditMetadataFieldService = new DsoEditMetadataFieldServiceStub();
|
||||||
findByHref: createSuccessfulRemoteDataObject$(item),
|
|
||||||
});
|
|
||||||
vocabularyServiceStub = new VocabularyServiceStub();
|
|
||||||
registryService = jasmine.createSpyObj('registryService', {
|
|
||||||
queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)),
|
|
||||||
});
|
|
||||||
notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(waitForAsync(async () => {
|
beforeEach(waitForAsync(async () => {
|
||||||
@@ -175,18 +68,13 @@ describe('DsoEditMetadataValueComponent', () => {
|
|||||||
authority: undefined,
|
authority: undefined,
|
||||||
});
|
});
|
||||||
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
||||||
dso = Object.assign(new DSpaceObject(), {
|
|
||||||
_links: {
|
|
||||||
self: { href: 'fake-dso-url/dso' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
initServices();
|
initServices();
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
TranslateModule.forRoot(),
|
TranslateModule.forRoot(),
|
||||||
RouterTestingModule.withRoutes([]),
|
RouterModule.forRoot([]),
|
||||||
DsoEditMetadataValueComponent,
|
DsoEditMetadataValueComponent,
|
||||||
VarDirective,
|
VarDirective,
|
||||||
BtnDisabledDirective,
|
BtnDisabledDirective,
|
||||||
@@ -194,16 +82,16 @@ describe('DsoEditMetadataValueComponent', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: RelationshipDataService, useValue: relationshipService },
|
{ provide: RelationshipDataService, useValue: relationshipService },
|
||||||
{ provide: DSONameService, useValue: dsoNameService },
|
{ provide: DSONameService, useValue: dsoNameService },
|
||||||
{ provide: VocabularyService, useValue: vocabularyServiceStub },
|
{ provide: DsoEditMetadataFieldService, useValue: dsoEditMetadataFieldService },
|
||||||
{ provide: ItemDataService, useValue: itemService },
|
|
||||||
{ provide: RegistryService, useValue: registryService },
|
|
||||||
{ provide: NotificationsService, useValue: notificationsService },
|
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA],
|
schemas: [NO_ERRORS_SCHEMA],
|
||||||
})
|
})
|
||||||
.overrideComponent(DsoEditMetadataValueComponent, {
|
.overrideComponent(DsoEditMetadataValueComponent, {
|
||||||
remove: {
|
remove: {
|
||||||
imports: [DsDynamicOneboxComponent, DsDynamicScrollableDropdownComponent, ThemedTypeBadgeComponent],
|
imports: [
|
||||||
|
DsoEditMetadataValueFieldLoaderComponent,
|
||||||
|
ThemedTypeBadgeComponent,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
@@ -213,7 +101,6 @@ describe('DsoEditMetadataValueComponent', () => {
|
|||||||
fixture = TestBed.createComponent(DsoEditMetadataValueComponent);
|
fixture = TestBed.createComponent(DsoEditMetadataValueComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
component.mdValue = editMetadataValue;
|
component.mdValue = editMetadataValue;
|
||||||
component.dso = dso;
|
|
||||||
component.saving$ = of(false);
|
component.saving$ = of(false);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
@@ -299,219 +186,6 @@ describe('DsoEditMetadataValueComponent', () => {
|
|||||||
assertButton(DRAG_BTN, true, false);
|
assertButton(DRAG_BTN, true, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when the metadata field not uses a vocabulary and is editing', () => {
|
|
||||||
beforeEach(waitForAsync(() => {
|
|
||||||
spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(null, 204));
|
|
||||||
metadataValue = Object.assign(new MetadataValue(), {
|
|
||||||
value: 'Regular value',
|
|
||||||
language: 'en',
|
|
||||||
place: 0,
|
|
||||||
authority: null,
|
|
||||||
});
|
|
||||||
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
|
||||||
editMetadataValue.editing = true;
|
|
||||||
component.mdValue = editMetadataValue;
|
|
||||||
component.mdField = 'metadata.regular';
|
|
||||||
component.ngOnInit();
|
|
||||||
fixture.detectChanges();
|
|
||||||
}));
|
|
||||||
|
|
||||||
it('should render a textarea', () => {
|
|
||||||
expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled();
|
|
||||||
expect(fixture.debugElement.query(By.css('textarea'))).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when the metadata field uses a scrollable vocabulary and is editing', () => {
|
|
||||||
beforeEach(waitForAsync(() => {
|
|
||||||
spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyScrollable));
|
|
||||||
metadataValue = Object.assign(new MetadataValue(), {
|
|
||||||
value: 'Authority Controlled value',
|
|
||||||
language: 'en',
|
|
||||||
place: 0,
|
|
||||||
authority: null,
|
|
||||||
});
|
|
||||||
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
|
||||||
editMetadataValue.editing = true;
|
|
||||||
component.mdValue = editMetadataValue;
|
|
||||||
component.mdField = 'metadata.scrollable';
|
|
||||||
component.ngOnInit();
|
|
||||||
fixture.detectChanges();
|
|
||||||
}));
|
|
||||||
|
|
||||||
it('should render the DsDynamicScrollableDropdownComponent', () => {
|
|
||||||
expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled();
|
|
||||||
expect(fixture.debugElement.query(By.css('ds-dynamic-scrollable-dropdown'))).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getModel should return a DynamicScrollableDropdownModel', () => {
|
|
||||||
const model = component.getModel();
|
|
||||||
|
|
||||||
expect(model instanceof DynamicScrollableDropdownModel).toBe(true);
|
|
||||||
expect(model.vocabularyOptions.name).toBe(mockVocabularyScrollable.name);
|
|
||||||
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when the metadata field uses a hierarchical vocabulary and is editing', () => {
|
|
||||||
beforeEach(waitForAsync(() => {
|
|
||||||
spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyHierarchical));
|
|
||||||
metadataValue = Object.assign(new MetadataValue(), {
|
|
||||||
value: 'Authority Controlled value',
|
|
||||||
language: 'en',
|
|
||||||
place: 0,
|
|
||||||
authority: null,
|
|
||||||
});
|
|
||||||
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
|
||||||
editMetadataValue.editing = true;
|
|
||||||
component.mdValue = editMetadataValue;
|
|
||||||
component.mdField = 'metadata.hierarchical';
|
|
||||||
component.ngOnInit();
|
|
||||||
fixture.detectChanges();
|
|
||||||
}));
|
|
||||||
|
|
||||||
it('should render the DsDynamicOneboxComponent', () => {
|
|
||||||
expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled();
|
|
||||||
expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getModel should return a DynamicOneboxModel', () => {
|
|
||||||
const model = component.getModel();
|
|
||||||
|
|
||||||
expect(model instanceof DynamicOneboxModel).toBe(true);
|
|
||||||
expect(model.vocabularyOptions.name).toBe(mockVocabularyHierarchical.name);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when the metadata field uses a suggester vocabulary and is editing', () => {
|
|
||||||
beforeEach(waitForAsync(() => {
|
|
||||||
spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularySuggester));
|
|
||||||
spyOn(component.confirm, 'emit');
|
|
||||||
metadataValue = Object.assign(new MetadataValue(), {
|
|
||||||
value: 'Authority Controlled value',
|
|
||||||
language: 'en',
|
|
||||||
place: 0,
|
|
||||||
authority: 'authority-key',
|
|
||||||
confidence: ConfidenceType.CF_UNCERTAIN,
|
|
||||||
});
|
|
||||||
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
|
||||||
editMetadataValue.editing = true;
|
|
||||||
component.mdValue = editMetadataValue;
|
|
||||||
component.mdField = 'metadata.suggester';
|
|
||||||
component.ngOnInit();
|
|
||||||
fixture.detectChanges();
|
|
||||||
}));
|
|
||||||
|
|
||||||
it('should render the DsDynamicOneboxComponent', () => {
|
|
||||||
expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled();
|
|
||||||
expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getModel should return a DynamicOneboxModel', () => {
|
|
||||||
const model = component.getModel();
|
|
||||||
|
|
||||||
expect(model instanceof DynamicOneboxModel).toBe(true);
|
|
||||||
expect(model.vocabularyOptions.name).toBe(mockVocabularySuggester.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('authority key edition', () => {
|
|
||||||
|
|
||||||
it('should update confidence to CF_NOVALUE when authority is cleared', () => {
|
|
||||||
component.mdValue.newValue.authority = '';
|
|
||||||
|
|
||||||
component.onChangeAuthorityKey();
|
|
||||||
|
|
||||||
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_NOVALUE);
|
|
||||||
expect(component.confirm.emit).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update confidence to CF_ACCEPTED when authority key is edited', () => {
|
|
||||||
component.mdValue.newValue.authority = 'newAuthority';
|
|
||||||
component.mdValue.originalValue.authority = 'oldAuthority';
|
|
||||||
|
|
||||||
component.onChangeAuthorityKey();
|
|
||||||
|
|
||||||
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED);
|
|
||||||
expect(component.confirm.emit).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not update confidence when authority key remains the same', () => {
|
|
||||||
component.mdValue.newValue.authority = 'sameAuthority';
|
|
||||||
component.mdValue.originalValue.authority = 'sameAuthority';
|
|
||||||
|
|
||||||
component.onChangeAuthorityKey();
|
|
||||||
|
|
||||||
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNCERTAIN);
|
|
||||||
expect(component.confirm.emit).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onChangeEditingAuthorityStatus with true when clicking the lock button', () => {
|
|
||||||
spyOn(component, 'onChangeEditingAuthorityStatus');
|
|
||||||
const lockButton = fixture.nativeElement.querySelector('#metadata-confirm-btn');
|
|
||||||
|
|
||||||
lockButton.click();
|
|
||||||
|
|
||||||
expect(component.onChangeEditingAuthorityStatus).toHaveBeenCalledWith(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should disable the input when editingAuthority is false', (done) => {
|
|
||||||
component.editingAuthority = false;
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
fixture.whenStable().then(() => {
|
|
||||||
const inputElement = fixture.nativeElement.querySelector('input[data-test="authority-input"]');
|
|
||||||
expect(inputElement.disabled).toBeTruthy();
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should enable the input when editingAuthority is true', (done) => {
|
|
||||||
component.editingAuthority = true;
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
fixture.whenStable().then(() => {
|
|
||||||
const inputElement = fixture.nativeElement.querySelector('input[data-test="authority-input"]');
|
|
||||||
expect(inputElement.disabled).toBeFalsy();
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update mdValue.newValue properties when authority is present', () => {
|
|
||||||
const event = {
|
|
||||||
value: 'Some value',
|
|
||||||
authority: 'Some authority',
|
|
||||||
};
|
|
||||||
|
|
||||||
component.onChangeAuthorityField(event);
|
|
||||||
|
|
||||||
expect(component.mdValue.newValue.value).toBe(event.value);
|
|
||||||
expect(component.mdValue.newValue.authority).toBe(event.authority);
|
|
||||||
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED);
|
|
||||||
expect(component.confirm.emit).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update mdValue.newValue properties when authority is not present', () => {
|
|
||||||
const event = {
|
|
||||||
value: 'Some value',
|
|
||||||
authority: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
component.onChangeAuthorityField(event);
|
|
||||||
|
|
||||||
expect(component.mdValue.newValue.value).toBe(event.value);
|
|
||||||
expect(component.mdValue.newValue.authority).toBeNull();
|
|
||||||
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNSET);
|
|
||||||
expect(component.confirm.emit).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
function assertButton(name: string, exists: boolean, disabled: boolean = false): void {
|
function assertButton(name: string, exists: boolean, disabled: boolean = false): void {
|
||||||
describe(`${name} button`, () => {
|
describe(`${name} button`, () => {
|
||||||
let btn: DebugElement;
|
let btn: DebugElement;
|
||||||
|
@@ -7,7 +7,6 @@ import {
|
|||||||
NgClass,
|
NgClass,
|
||||||
} from '@angular/common';
|
} from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectorRef,
|
|
||||||
Component,
|
Component,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
Input,
|
Input,
|
||||||
@@ -16,96 +15,67 @@ import {
|
|||||||
Output,
|
Output,
|
||||||
SimpleChanges,
|
SimpleChanges,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import { FormsModule } from '@angular/forms';
|
||||||
FormsModule,
|
|
||||||
UntypedFormControl,
|
|
||||||
UntypedFormGroup,
|
|
||||||
} from '@angular/forms';
|
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import {
|
import {
|
||||||
TranslateModule,
|
|
||||||
TranslateService,
|
|
||||||
} from '@ngx-translate/core';
|
|
||||||
import {
|
|
||||||
BehaviorSubject,
|
|
||||||
EMPTY,
|
EMPTY,
|
||||||
Observable,
|
Observable,
|
||||||
of as observableOf,
|
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import {
|
import { map } from 'rxjs/operators';
|
||||||
map,
|
|
||||||
switchMap,
|
|
||||||
take,
|
|
||||||
tap,
|
|
||||||
} from 'rxjs/operators';
|
|
||||||
import { RegistryService } from 'src/app/core/registry/registry.service';
|
|
||||||
import { VocabularyService } from 'src/app/core/submission/vocabularies/vocabulary.service';
|
|
||||||
import { NotificationsService } from 'src/app/shared/notifications/notifications.service';
|
|
||||||
|
|
||||||
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
|
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
|
||||||
import { ItemDataService } from '../../../core/data/item-data.service';
|
|
||||||
import { RelationshipDataService } from '../../../core/data/relationship-data.service';
|
import { RelationshipDataService } from '../../../core/data/relationship-data.service';
|
||||||
import { MetadataService } from '../../../core/metadata/metadata.service';
|
import { MetadataService } from '../../../core/metadata/metadata.service';
|
||||||
import { Collection } from '../../../core/shared/collection.model';
|
|
||||||
import { ConfidenceType } from '../../../core/shared/confidence-type';
|
import { ConfidenceType } from '../../../core/shared/confidence-type';
|
||||||
|
import { Context } from '../../../core/shared/context.model';
|
||||||
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
||||||
import { Item } from '../../../core/shared/item.model';
|
|
||||||
import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
|
import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
|
||||||
import {
|
import {
|
||||||
MetadataRepresentation,
|
MetadataRepresentation,
|
||||||
MetadataRepresentationType,
|
MetadataRepresentationType,
|
||||||
} from '../../../core/shared/metadata-representation/metadata-representation.model';
|
} from '../../../core/shared/metadata-representation/metadata-representation.model';
|
||||||
import {
|
|
||||||
getFirstCompletedRemoteData,
|
|
||||||
getFirstSucceededRemoteData,
|
|
||||||
getFirstSucceededRemoteDataPayload,
|
|
||||||
getRemoteDataPayload,
|
|
||||||
metadataFieldsToString,
|
|
||||||
} from '../../../core/shared/operators';
|
|
||||||
import { Vocabulary } from '../../../core/submission/vocabularies/models/vocabulary.model';
|
import { Vocabulary } from '../../../core/submission/vocabularies/models/vocabulary.model';
|
||||||
import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model';
|
|
||||||
import { getItemPageRoute } from '../../../item-page/item-page-routing-paths';
|
import { getItemPageRoute } from '../../../item-page/item-page-routing-paths';
|
||||||
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
|
import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive';
|
||||||
import { isNotEmpty } from '../../../shared/empty.util';
|
import { hasValue } from '../../../shared/empty.util';
|
||||||
import { DsDynamicOneboxComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component';
|
|
||||||
import {
|
|
||||||
DsDynamicOneboxModelConfig,
|
|
||||||
DynamicOneboxModel,
|
|
||||||
} from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model';
|
|
||||||
import { DsDynamicScrollableDropdownComponent } from '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component';
|
|
||||||
import {
|
|
||||||
DynamicScrollableDropdownModel,
|
|
||||||
DynamicScrollableDropdownModelConfig,
|
|
||||||
} from '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model';
|
|
||||||
import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model';
|
|
||||||
import { AuthorityConfidenceStateDirective } from '../../../shared/form/directives/authority-confidence-state.directive';
|
import { AuthorityConfidenceStateDirective } from '../../../shared/form/directives/authority-confidence-state.directive';
|
||||||
import { ThemedTypeBadgeComponent } from '../../../shared/object-collection/shared/badges/type-badge/themed-type-badge.component';
|
import { ThemedTypeBadgeComponent } from '../../../shared/object-collection/shared/badges/type-badge/themed-type-badge.component';
|
||||||
import { DebounceDirective } from '../../../shared/utils/debounce.directive';
|
import { DebounceDirective } from '../../../shared/utils/debounce.directive';
|
||||||
import { followLink } from '../../../shared/utils/follow-link-config.model';
|
|
||||||
import { VarDirective } from '../../../shared/utils/var.directive';
|
|
||||||
import {
|
import {
|
||||||
DsoEditMetadataChangeType,
|
DsoEditMetadataChangeType,
|
||||||
DsoEditMetadataValue,
|
DsoEditMetadataValue,
|
||||||
} from '../dso-edit-metadata-form';
|
} from '../dso-edit-metadata-form';
|
||||||
|
import { DsoEditMetadataFieldService } from '../dso-edit-metadata-value-field/dso-edit-metadata-field.service';
|
||||||
|
import { EditMetadataValueFieldType } from '../dso-edit-metadata-value-field/dso-edit-metadata-field-type.enum';
|
||||||
|
import { DsoEditMetadataValueFieldLoaderComponent } from '../dso-edit-metadata-value-field/dso-edit-metadata-value-field-loader/dso-edit-metadata-value-field-loader.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-dso-edit-metadata-value',
|
selector: 'ds-dso-edit-metadata-value',
|
||||||
styleUrls: ['./dso-edit-metadata-value.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'],
|
styleUrls: ['./dso-edit-metadata-value.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'],
|
||||||
templateUrl: './dso-edit-metadata-value.component.html',
|
templateUrl: './dso-edit-metadata-value.component.html',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [VarDirective, CdkDrag, NgClass, FormsModule, DebounceDirective, RouterLink, ThemedTypeBadgeComponent, NgbTooltipModule, CdkDragHandle, AsyncPipe, TranslateModule, DsDynamicScrollableDropdownComponent, DsDynamicOneboxComponent, AuthorityConfidenceStateDirective, BtnDisabledDirective],
|
imports: [CdkDrag, NgClass, FormsModule, DebounceDirective, RouterLink, ThemedTypeBadgeComponent, NgbTooltipModule, CdkDragHandle, AsyncPipe, TranslateModule, AuthorityConfidenceStateDirective, BtnDisabledDirective, DsoEditMetadataValueFieldLoaderComponent],
|
||||||
})
|
})
|
||||||
/**
|
/**
|
||||||
* Component displaying a single editable row for a metadata value
|
* Component displaying a single editable row for a metadata value
|
||||||
*/
|
*/
|
||||||
export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
|
export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
|
||||||
|
|
||||||
|
@Input() context: Context;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The parent {@link DSpaceObject} to display a metadata form for
|
* The parent {@link DSpaceObject} to display a metadata form for
|
||||||
* Also used to determine metadata-representations in case of virtual metadata
|
* Also used to determine metadata-representations in case of virtual metadata
|
||||||
*/
|
*/
|
||||||
@Input() dso: DSpaceObject;
|
@Input() dso: DSpaceObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata field that is being edited
|
||||||
|
*/
|
||||||
|
@Input() mdField: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editable metadata value to show
|
* Editable metadata value to show
|
||||||
*/
|
*/
|
||||||
@@ -129,11 +99,6 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
|
|||||||
*/
|
*/
|
||||||
@Input() isOnlyValue = false;
|
@Input() isOnlyValue = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* MetadataField to edit
|
|
||||||
*/
|
|
||||||
@Input() mdField?: string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emits when the user clicked edit
|
* Emits when the user clicked edit
|
||||||
*/
|
*/
|
||||||
@@ -165,12 +130,6 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
|
|||||||
*/
|
*/
|
||||||
public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
|
public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
|
||||||
|
|
||||||
/**
|
|
||||||
* The ConfidenceType enumeration for access in the component's template
|
|
||||||
* @type {ConfidenceType}
|
|
||||||
*/
|
|
||||||
public ConfidenceTypeEnum = ConfidenceType;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The item this metadata value represents in case it's virtual (if any, otherwise null)
|
* The item this metadata value represents in case it's virtual (if any, otherwise null)
|
||||||
*/
|
*/
|
||||||
@@ -187,56 +146,28 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
|
|||||||
mdRepresentationName$: Observable<string | null>;
|
mdRepresentationName$: Observable<string | null>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the authority field is currently being edited
|
* The type of edit field that should be displayed
|
||||||
*/
|
*/
|
||||||
public editingAuthority = false;
|
fieldType$: Observable<EditMetadataValueFieldType>;
|
||||||
|
|
||||||
|
readonly ConfidenceTypeEnum = ConfidenceType;
|
||||||
/**
|
|
||||||
* Whether or not the free-text editing is enabled when scrollable dropdown or hierarchical vocabulary is used
|
|
||||||
*/
|
|
||||||
public enabledFreeTextEditing = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Field group used by authority field
|
|
||||||
* @type {UntypedFormGroup}
|
|
||||||
*/
|
|
||||||
group = new UntypedFormGroup({ authorityField : new UntypedFormControl() });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Model to use for editing authorities values
|
|
||||||
*/
|
|
||||||
private model$: BehaviorSubject<DynamicOneboxModel | DynamicScrollableDropdownModel> = new BehaviorSubject(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Observable with information about the authority vocabulary used
|
|
||||||
*/
|
|
||||||
private vocabulary$: Observable<Vocabulary>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Observables with information about the authority vocabulary type used
|
|
||||||
*/
|
|
||||||
private isAuthorityControlled$: Observable<boolean>;
|
|
||||||
private isHierarchicalVocabulary$: Observable<boolean>;
|
|
||||||
private isScrollableVocabulary$: Observable<boolean>;
|
|
||||||
private isSuggesterVocabulary$: Observable<boolean>;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected relationshipService: RelationshipDataService,
|
protected relationshipService: RelationshipDataService,
|
||||||
protected dsoNameService: DSONameService,
|
protected dsoNameService: DSONameService,
|
||||||
protected vocabularyService: VocabularyService,
|
|
||||||
protected itemService: ItemDataService,
|
|
||||||
protected cdr: ChangeDetectorRef,
|
|
||||||
protected registryService: RegistryService,
|
|
||||||
protected notificationsService: NotificationsService,
|
|
||||||
protected translate: TranslateService,
|
|
||||||
protected metadataService: MetadataService,
|
protected metadataService: MetadataService,
|
||||||
|
protected dsoEditMetadataFieldService: DsoEditMetadataFieldService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.initVirtualProperties();
|
this.initVirtualProperties();
|
||||||
this.initAuthorityProperties();
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes.mdField) {
|
||||||
|
this.fieldType$ = this.getFieldType();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -259,252 +190,20 @@ export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialise potential properties of a authority controlled metadata field
|
* Retrieves the {@link EditMetadataValueFieldType} to be displayed for the current field while in edit mode.
|
||||||
*/
|
*/
|
||||||
initAuthorityProperties(): void {
|
getFieldType(): Observable<EditMetadataValueFieldType> {
|
||||||
|
return this.dsoEditMetadataFieldService.findDsoFieldVocabulary(this.dso, this.mdField).pipe(
|
||||||
if (isNotEmpty(this.mdField)) {
|
map((vocabulary: Vocabulary) => {
|
||||||
|
if (hasValue(vocabulary)) {
|
||||||
const owningCollection$: Observable<Collection> = this.itemService.findByHref(this.dso._links.self.href, true, true, followLink('owningCollection'))
|
return EditMetadataValueFieldType.AUTHORITY;
|
||||||
.pipe(
|
|
||||||
getFirstSucceededRemoteData(),
|
|
||||||
getRemoteDataPayload(),
|
|
||||||
switchMap((item: Item) => item.owningCollection),
|
|
||||||
getFirstSucceededRemoteData(),
|
|
||||||
getRemoteDataPayload(),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.vocabulary$ = owningCollection$.pipe(
|
|
||||||
switchMap((c: Collection) => this.vocabularyService
|
|
||||||
.getVocabularyByMetadataAndCollection(this.mdField, c.uuid)
|
|
||||||
.pipe(
|
|
||||||
getFirstSucceededRemoteDataPayload(),
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.vocabulary$ = observableOf(undefined);
|
|
||||||
}
|
}
|
||||||
|
if (this.mdField === 'dspace.entity.type') {
|
||||||
this.isAuthorityControlled$ = this.vocabulary$.pipe(
|
return EditMetadataValueFieldType.ENTITY_TYPE;
|
||||||
// Create the model used by the authority fields to ensure its existence when the field is initialized
|
|
||||||
tap((v: Vocabulary) => this.model$.next(this.createModel(v))),
|
|
||||||
map((result: Vocabulary) => isNotEmpty(result)),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.isHierarchicalVocabulary$ = this.vocabulary$.pipe(
|
|
||||||
map((result: Vocabulary) => isNotEmpty(result) && result.hierarchical),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.isScrollableVocabulary$ = this.vocabulary$.pipe(
|
|
||||||
map((result: Vocabulary) => isNotEmpty(result) && result.scrollable),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.isSuggesterVocabulary$ = this.vocabulary$.pipe(
|
|
||||||
map((result: Vocabulary) => isNotEmpty(result) && !result.hierarchical && !result.scrollable),
|
|
||||||
);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model based on the
|
|
||||||
* vocabulary used.
|
|
||||||
*/
|
|
||||||
private createModel(vocabulary: Vocabulary): DynamicOneboxModel | DynamicScrollableDropdownModel {
|
|
||||||
if (isNotEmpty(vocabulary)) {
|
|
||||||
let formFieldValue;
|
|
||||||
if (isNotEmpty(this.mdValue.newValue.value)) {
|
|
||||||
formFieldValue = new FormFieldMetadataValueObject();
|
|
||||||
formFieldValue.value = this.mdValue.newValue.value;
|
|
||||||
formFieldValue.display = this.mdValue.newValue.value;
|
|
||||||
if (this.mdValue.newValue.authority) {
|
|
||||||
formFieldValue.authority = this.mdValue.newValue.authority;
|
|
||||||
formFieldValue.confidence = this.mdValue.newValue.confidence;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
formFieldValue = this.mdValue.newValue.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vocabularyOptions = vocabulary ? {
|
|
||||||
closed: false,
|
|
||||||
name: vocabulary.name,
|
|
||||||
} as VocabularyOptions : null;
|
|
||||||
|
|
||||||
if (!vocabulary.scrollable) {
|
|
||||||
const model: DsDynamicOneboxModelConfig = {
|
|
||||||
id: 'authorityField',
|
|
||||||
label: `${this.dsoType}.edit.metadata.edit.value`,
|
|
||||||
vocabularyOptions: vocabularyOptions,
|
|
||||||
metadataFields: [this.mdField],
|
|
||||||
value: formFieldValue,
|
|
||||||
repeatable: false,
|
|
||||||
submissionId: 'edit-metadata',
|
|
||||||
hasSelectableMetadata: false,
|
|
||||||
};
|
|
||||||
return new DynamicOneboxModel(model);
|
|
||||||
} else {
|
|
||||||
const model: DynamicScrollableDropdownModelConfig = {
|
|
||||||
id: 'authorityField',
|
|
||||||
label: `${this.dsoType}.edit.metadata.edit.value`,
|
|
||||||
placeholder: `${this.dsoType}.edit.metadata.edit.value`,
|
|
||||||
vocabularyOptions: vocabularyOptions,
|
|
||||||
metadataFields: [this.mdField],
|
|
||||||
value: formFieldValue,
|
|
||||||
repeatable: false,
|
|
||||||
submissionId: 'edit-metadata',
|
|
||||||
hasSelectableMetadata: false,
|
|
||||||
maxOptions: 10,
|
|
||||||
};
|
|
||||||
return new DynamicScrollableDropdownModel(model);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change callback for the component. Check if the mdField has changed to retrieve whether it is metadata
|
|
||||||
* that uses a controlled vocabulary and update the related properties
|
|
||||||
*
|
|
||||||
* @param {SimpleChanges} changes
|
|
||||||
*/
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
|
||||||
if (isNotEmpty(changes.mdField) && !changes.mdField.firstChange) {
|
|
||||||
if (isNotEmpty(changes.mdField.currentValue) ) {
|
|
||||||
if (isNotEmpty(changes.mdField.previousValue) &&
|
|
||||||
changes.mdField.previousValue !== changes.mdField.currentValue) {
|
|
||||||
// Clear authority value in case it has been assigned with the previous metadataField used
|
|
||||||
this.mdValue.newValue.authority = null;
|
|
||||||
this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only ask if the current mdField have a period character to reduce request
|
|
||||||
if (changes.mdField.currentValue.includes('.')) {
|
|
||||||
this.validateMetadataField().subscribe((isValid: boolean) => {
|
|
||||||
if (isValid) {
|
|
||||||
this.initAuthorityProperties();
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the metadata field to check if it exists on the server and return an observable boolean for success/error
|
|
||||||
*/
|
|
||||||
validateMetadataField(): Observable<boolean> {
|
|
||||||
return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe(
|
|
||||||
getFirstCompletedRemoteData(),
|
|
||||||
switchMap((rd) => {
|
|
||||||
if (rd.hasSucceeded) {
|
|
||||||
return observableOf(rd).pipe(
|
|
||||||
metadataFieldsToString(),
|
|
||||||
take(1),
|
|
||||||
map((fields: string[]) => fields.indexOf(this.mdField) > -1),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage);
|
|
||||||
return [false];
|
|
||||||
}
|
}
|
||||||
|
return EditMetadataValueFieldType.PLAIN_TEXT;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if this field use a authority vocabulary
|
|
||||||
*/
|
|
||||||
isAuthorityControlled(): Observable<boolean> {
|
|
||||||
return this.isAuthorityControlled$;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if configured vocabulary is Hierarchical or not
|
|
||||||
*/
|
|
||||||
isHierarchicalVocabulary(): Observable<boolean> {
|
|
||||||
return this.isHierarchicalVocabulary$;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if configured vocabulary is Scrollable or not
|
|
||||||
*/
|
|
||||||
isScrollableVocabulary(): Observable<boolean> {
|
|
||||||
return this.isScrollableVocabulary$;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if configured vocabulary is Suggester or not
|
|
||||||
* (a vocabulary not Scrollable and not Hierarchical that uses an autocomplete field)
|
|
||||||
*/
|
|
||||||
isSuggesterVocabulary(): Observable<boolean> {
|
|
||||||
return this.isSuggesterVocabulary$;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process the change of authority field value updating the authority key and confidence as necessary
|
|
||||||
*/
|
|
||||||
onChangeAuthorityField(event): void {
|
|
||||||
if (event) {
|
|
||||||
this.mdValue.newValue.value = event.value;
|
|
||||||
if (event.authority) {
|
|
||||||
this.mdValue.newValue.authority = event.authority;
|
|
||||||
this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED;
|
|
||||||
} else {
|
|
||||||
this.mdValue.newValue.authority = null;
|
|
||||||
this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET;
|
|
||||||
}
|
|
||||||
this.confirm.emit(false);
|
|
||||||
} else {
|
|
||||||
// The event is undefined when the user clears the selection in scrollable dropdown
|
|
||||||
this.mdValue.newValue.value = '';
|
|
||||||
this.mdValue.newValue.authority = null;
|
|
||||||
this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET;
|
|
||||||
this.confirm.emit(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model used
|
|
||||||
* for the authority field
|
|
||||||
*/
|
|
||||||
getModel(): DynamicOneboxModel | DynamicScrollableDropdownModel {
|
|
||||||
return this.model$.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change the status of the editingAuthority property
|
|
||||||
* @param status
|
|
||||||
*/
|
|
||||||
onChangeEditingAuthorityStatus(status: boolean) {
|
|
||||||
this.editingAuthority = status;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes the change in authority value, updating the confidence as necessary.
|
|
||||||
* If the authority key is cleared, the confidence is set to {@link ConfidenceType.CF_NOVALUE}.
|
|
||||||
* If the authority key is edited and differs from the original, the confidence is set to {@link ConfidenceType.CF_ACCEPTED}.
|
|
||||||
*/
|
|
||||||
onChangeAuthorityKey() {
|
|
||||||
if (this.mdValue.newValue.authority === '') {
|
|
||||||
this.mdValue.newValue.confidence = ConfidenceType.CF_NOVALUE;
|
|
||||||
this.confirm.emit(false);
|
|
||||||
} else if (this.mdValue.newValue.authority !== this.mdValue.originalValue.authority) {
|
|
||||||
this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED;
|
|
||||||
this.confirm.emit(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles the free-text ediitng mode
|
|
||||||
*/
|
|
||||||
toggleFreeTextEdition() {
|
|
||||||
if (this.enabledFreeTextEditing) {
|
|
||||||
if (this.getModel().value !== this.mdValue.newValue.value) {
|
|
||||||
// Reload the model to adapt it to the new possible value modified during free text editing
|
|
||||||
this.initAuthorityProperties();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.enabledFreeTextEditing = !this.enabledFreeTextEditing;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user