diff --git a/.github/workflows/codescan.yml b/.github/workflows/codescan.yml index d96e786cc37..65cffdfcd9f 100644 --- a/.github/workflows/codescan.yml +++ b/.github/workflows/codescan.yml @@ -40,14 +40,14 @@ jobs: # Initializes the CodeQL tools for scanning. # https://github.com/github/codeql-action - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: javascript # Autobuild attempts to build any compiled languages - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # Perform GitHub Code Scanning. - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 \ No newline at end of file + uses: github/codeql-action/analyze@v3 diff --git a/package.json b/package.json index 997a8bb85df..fc59134ba2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dspace-angular", - "version": "8.2.0-next", + "version": "8.4.0-next", "scripts": { "ng": "ng", "config:watch": "nodemon", @@ -67,7 +67,7 @@ "@angular/platform-server": "^17.3.11", "@angular/router": "^17.3.11", "@angular/ssr": "^17.3.17", - "@babel/runtime": "7.27.6", + "@babel/runtime": "7.28.4", "@kolkov/ngx-gallery": "^2.0.1", "@ng-bootstrap/ng-bootstrap": "^11.0.0", "@ng-dynamic-forms/core": "^16.0.0", @@ -78,14 +78,14 @@ "@ngx-translate/core": "^14.0.0", "@nicky-lenaers/ngx-scroll-to": "^14.0.0", "angulartics2": "^12.2.0", - "axios": "^1.10.0", + "axios": "^1.13.2", "bootstrap": "^4.6.1", "cerialize": "0.1.18", "cli-progress": "^3.12.0", "colors": "^1.4.0", - "compression": "^1.8.0", + "compression": "^1.8.1", "cookie-parser": "1.4.7", - "core-js": "^3.42.0", + "core-js": "^3.47.0", "date-fns": "^2.29.3", "date-fns-tz": "^1.3.7", "deepmerge": "^4.3.1", @@ -96,9 +96,9 @@ "filesize": "^6.1.0", "http-proxy-middleware": "^2.0.9", "http-terminator": "^3.2.0", - "isbot": "^5.1.28", + "isbot": "^5.1.32", "js-cookie": "2.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "json5": "^2.2.3", "jsonschema": "1.5.0", "jwt-decode": "^3.1.2", @@ -109,7 +109,7 @@ "mirador": "^3.4.3", "mirador-dl-plugin": "^0.13.0", "mirador-share-plugin": "^0.16.0", - "morgan": "^1.10.0", + "morgan": "^1.10.1", "ng2-file-upload": "5.0.0", "ng2-nouislider": "^2.0.0", "ngx-infinite-scroll": "^16.0.0", @@ -147,28 +147,28 @@ "@types/grecaptcha": "^3.0.9", "@types/jasmine": "~3.6.0", "@types/js-cookie": "2.2.6", - "@types/lodash": "^4.17.17", + "@types/lodash": "^4.17.21", "@types/node": "^14.14.9", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@typescript-eslint/rule-tester": "^7.2.0", "@typescript-eslint/utils": "^7.2.0", - "axe-core": "^4.10.3", + "axe-core": "^4.11.0", "compression-webpack-plugin": "^9.2.0", "copy-webpack-plugin": "^6.4.1", "cross-env": "^7.0.3", - "csstype": "^3.1.3", + "csstype": "^3.2.3", "cypress": "^13.17.0", - "cypress-axe": "^1.6.0", + "cypress-axe": "^1.7.0", "deep-freeze": "0.0.1", "eslint": "^8.39.0", "eslint-plugin-deprecation": "^1.4.1", "eslint-plugin-dspace-angular-html": "link:./lint/dist/src/rules/html", "eslint-plugin-dspace-angular-ts": "link:./lint/dist/src/rules/ts", - "eslint-plugin-import": "^2.31.0", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-import-newlines": "^1.3.1", "eslint-plugin-jsdoc": "^45.0.0", - "eslint-plugin-jsonc": "^2.20.1", + "eslint-plugin-jsonc": "^2.21.0", "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-rxjs": "^5.0.3", "eslint-plugin-simple-import-sort": "^10.0.0", @@ -183,7 +183,7 @@ "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.5.0", "karma-mocha-reporter": "2.2.5", - "ng-mocks": "^14.13.5", + "ng-mocks": "^14.14.0", "ngx-mask": "14.2.4", "nodemon": "^2.0.22", "postcss": "^8.5", @@ -195,13 +195,13 @@ "react-copy-to-clipboard": "^5.1.0", "react-dom": "^16.14.0", "rimraf": "^3.0.2", - "sass": "~1.89.2", + "sass": "~1.94.2", "sass-loader": "^12.6.0", "sass-resources-loader": "^2.2.5", "ts-node": "^8.10.2", "typescript": "~5.4.5", - "webpack": "5.99.9", + "webpack": "5.101.0", "webpack-cli": "^5.1.4", - "webpack-dev-server": "^4.15.1" + "webpack-dev-server": "^5.2.2" } } diff --git a/scripts/sync-i18n-files.ts b/scripts/sync-i18n-files.ts index 170266b6a28..6b3881b3b82 100644 --- a/scripts/sync-i18n-files.ts +++ b/scripts/sync-i18n-files.ts @@ -38,11 +38,13 @@ function parseCliInput() { .usage('([-d ] [-s ]) || (-t (-i | -o ) [-s ])') .parse(process.argv); - if (!program.targetFile) { + const sourceFile = program.opts().sourceFile; + + if (!program.targetFile) { fs.readdirSync(projectRoot(LANGUAGE_FILES_LOCATION)).forEach(file => { - if (!program.sourceFile.toString().endsWith(file)) { + if (!sourceFile.toString().endsWith(file)) { const targetFileLocation = projectRoot(LANGUAGE_FILES_LOCATION + "/" + file); - console.log('Syncing file at: ' + targetFileLocation + ' with source file at: ' + program.sourceFile); + console.log('Syncing file at: ' + targetFileLocation + ' with source file at: ' + sourceFile); if (program.outputDir) { if (!fs.existsSync(program.outputDir)) { fs.mkdirSync(program.outputDir); @@ -67,7 +69,7 @@ function parseCliInput() { console.log(program.outputHelp()); process.exit(1); } - if (!checkIfFileExists(program.sourceFile)) { + if (!checkIfFileExists(sourceFile)) { console.error('Path of source file is not valid.'); console.log(program.outputHelp()); process.exit(1); @@ -101,7 +103,7 @@ function syncFileWithSource(pathToTargetFile, pathToOutputFile) { targetLines.push(line.trim()); })); progressBar.update(10); - const sourceFile = readFileIfExists(program.sourceFile); + const sourceFile = readFileIfExists(program.opts().sourceFile); sourceFile.toString().split("\n").forEach((function (line) { sourceLines.push(line.trim()); })); diff --git a/server.ts b/server.ts index 84c07229472..1005374088d 100644 --- a/server.ts +++ b/server.ts @@ -269,6 +269,12 @@ function serverSideRender(req, res, next, sendToUser: boolean = true) { ], }) .then((html) => { + // If headers were already sent, then do nothing else, it is probably a + // redirect response + if (res.headersSent) { + return; + } + if (hasValue(html)) { // Replace REST URL with UI URL if (environment.ssr.replaceRestUrl && REST_BASE_URL !== environment.rest.baseUrl) { @@ -304,13 +310,24 @@ function serverSideRender(req, res, next, sendToUser: boolean = true) { }); } -/** - * Send back response to user to trigger direct client-side rendering (CSR) - * @param req current request - * @param res current response - */ +// Read file once at startup +const indexHtmlContent = readFileSync(indexHtml, 'utf8'); + function clientSideRender(req, res) { - res.sendFile(indexHtml); + const namespace = environment.ui.nameSpace || '/'; + let html = indexHtmlContent; + // Replace base href dynamically + html = html.replace( + //, + `` + ); + + // Replace REST URL with UI URL + if (environment.ssr.replaceRestUrl && REST_BASE_URL !== environment.rest.baseUrl) { + html = html.replace(new RegExp(REST_BASE_URL, 'g'), environment.rest.baseUrl); + } + + res.send(html); } @@ -561,8 +578,8 @@ function createHttpsServer(keys) { * Create an HTTP server with the configured port and host. */ function run() { - const port = environment.ui.port || 4000; - const host = environment.ui.host || '/'; + const port = environment.ui.port; + const host = environment.ui.host; // Start up the Node server const server = app(); diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index c62aa3253d3..0dd4515a73c 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -25,6 +25,7 @@ import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routin import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths'; import { authBlockingGuard } from './core/auth/auth-blocking.guard'; import { authenticatedGuard } from './core/auth/authenticated.guard'; +import { notAuthenticatedGuard } from './core/auth/not-authenticated.guard'; import { groupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; import { siteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { siteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard'; @@ -98,13 +99,13 @@ export const APP_ROUTES: Route[] = [ path: REGISTER_PATH, loadChildren: () => import('./register-page/register-page-routes') .then((m) => m.ROUTES), - canActivate: [siteRegisterGuard], + canActivate: [notAuthenticatedGuard, siteRegisterGuard], }, { path: FORGOT_PASSWORD_PATH, loadChildren: () => import('./forgot-password/forgot-password-routes') .then((m) => m.ROUTES), - canActivate: [endUserAgreementCurrentUserGuard, forgotPasswordCheckGuard], + canActivate: [notAuthenticatedGuard, endUserAgreementCurrentUserGuard, forgotPasswordCheckGuard], }, { path: COMMUNITY_MODULE_PATH, @@ -178,11 +179,13 @@ export const APP_ROUTES: Route[] = [ path: 'login', loadChildren: () => import('./login-page/login-page-routes') .then((m) => m.ROUTES), + canActivate: [notAuthenticatedGuard], }, { path: 'logout', loadChildren: () => import('./logout-page/logout-page-routes') .then((m) => m.ROUTES), + canActivate: [authenticatedGuard], }, { path: 'submit', diff --git a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts index c60e23e81e2..41fbf3d1e6a 100644 --- a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts +++ b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts @@ -161,7 +161,7 @@ export class CollectionItemMapperComponent implements OnInit { this.collectionName$ = this.collectionRD$.pipe( map((rd: RemoteData) => { - return this.dsoNameService.getName(rd.payload); + return this.dsoNameService.getName(rd.payload, true); }), ); this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; diff --git a/src/app/core/auth/models/auth.method-type.ts b/src/app/core/auth/models/auth.method-type.ts index 594d6d8b395..ef7a7304a06 100644 --- a/src/app/core/auth/models/auth.method-type.ts +++ b/src/app/core/auth/models/auth.method-type.ts @@ -3,7 +3,6 @@ export enum AuthMethodType { Shibboleth = 'shibboleth', Ldap = 'ldap', Ip = 'ip', - X509 = 'x509', Oidc = 'oidc', Orcid = 'orcid' } diff --git a/src/app/core/auth/models/auth.method.ts b/src/app/core/auth/models/auth.method.ts index b84e7a308af..267f7768c9c 100644 --- a/src/app/core/auth/models/auth.method.ts +++ b/src/app/core/auth/models/auth.method.ts @@ -22,10 +22,6 @@ export class AuthMethod { this.location = location; break; } - case 'x509': { - this.authMethodType = AuthMethodType.X509; - break; - } case 'password': { this.authMethodType = AuthMethodType.Password; break; diff --git a/src/app/core/auth/not-authenticated.guard.spec.ts b/src/app/core/auth/not-authenticated.guard.spec.ts new file mode 100644 index 00000000000..57102b48b66 --- /dev/null +++ b/src/app/core/auth/not-authenticated.guard.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from '@angular/core/testing'; +import { + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; +import { + firstValueFrom, + of, +} from 'rxjs'; +import { PAGE_NOT_FOUND_PATH } from 'src/app/app-routing-paths'; + +import { HardRedirectService } from '../services/hard-redirect.service'; +import { AuthService } from './auth.service'; +import { notAuthenticatedGuard } from './not-authenticated.guard'; + +describe('notAuthenticatedGuard', () => { + let authService: jasmine.SpyObj; + let hardRedirectService: jasmine.SpyObj; + const mockRoute = {} as ActivatedRouteSnapshot; + const mockState = {} as RouterStateSnapshot; + + beforeEach(() => { + const authSpy = jasmine.createSpyObj('AuthService', ['isAuthenticated']); + const redirectSpy = jasmine.createSpyObj('HardRedirectService', ['redirect']); + + TestBed.configureTestingModule({ + providers: [ + { provide: AuthService, useValue: authSpy }, + { provide: HardRedirectService, useValue: redirectSpy }, + ], + }); + + authService = TestBed.inject(AuthService) as jasmine.SpyObj; + hardRedirectService = TestBed.inject(HardRedirectService) as jasmine.SpyObj; + }); + + it('should block access and redirect if user is logged in', async () => { + authService.isAuthenticated.and.returnValue(of(true)); + + const result$ = TestBed.runInInjectionContext(() => + notAuthenticatedGuard(mockRoute, mockState), + ); + + const result = await firstValueFrom(result$ as any); + expect(result).toBe(false); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(PAGE_NOT_FOUND_PATH); + }); + + it('should allow access if user is not logged in', async () => { + authService.isAuthenticated.and.returnValue(of(false)); + + const result$ = TestBed.runInInjectionContext(() => + notAuthenticatedGuard(mockRoute, mockState), + ); + + const result = await firstValueFrom(result$ as any); + expect(result).toBe(true); + expect(hardRedirectService.redirect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/core/auth/not-authenticated.guard.ts b/src/app/core/auth/not-authenticated.guard.ts new file mode 100644 index 00000000000..db21a5c7a98 --- /dev/null +++ b/src/app/core/auth/not-authenticated.guard.ts @@ -0,0 +1,23 @@ +import { inject } from '@angular/core'; +import { CanActivateFn } from '@angular/router'; +import { map } from 'rxjs/operators'; +import { PAGE_NOT_FOUND_PATH } from 'src/app/app-routing-paths'; + +import { HardRedirectService } from '../services/hard-redirect.service'; +import { AuthService } from './auth.service'; + +export const notAuthenticatedGuard: CanActivateFn = () => { + const authService = inject(AuthService); + const redirectService = inject(HardRedirectService); + + return authService.isAuthenticated().pipe( + map((isLoggedIn) => { + if (isLoggedIn) { + redirectService.redirect(PAGE_NOT_FOUND_PATH); + return false; + } + + return true; + }), + ); +}; diff --git a/src/app/core/breadcrumbs/dso-name.service.spec.ts b/src/app/core/breadcrumbs/dso-name.service.spec.ts index 5f241b1a6cc..4bdd36a0c89 100644 --- a/src/app/core/breadcrumbs/dso-name.service.spec.ts +++ b/src/app/core/breadcrumbs/dso-name.service.spec.ts @@ -78,7 +78,7 @@ describe(`DSONameService`, () => { const result = service.getName(mockPerson); - expect((service as any).factories.Person).toHaveBeenCalledWith(mockPerson); + expect((service as any).factories.Person).toHaveBeenCalledWith(mockPerson, undefined); expect(result).toBe('Bingo!'); }); @@ -87,7 +87,7 @@ describe(`DSONameService`, () => { const result = service.getName(mockOrgUnit); - expect((service as any).factories.OrgUnit).toHaveBeenCalledWith(mockOrgUnit); + expect((service as any).factories.OrgUnit).toHaveBeenCalledWith(mockOrgUnit, undefined); expect(result).toBe('Bingo!'); }); @@ -96,7 +96,7 @@ describe(`DSONameService`, () => { const result = service.getName(mockEPerson); - expect((service as any).factories.EPerson).toHaveBeenCalledWith(mockEPerson); + expect((service as any).factories.EPerson).toHaveBeenCalledWith(mockEPerson, undefined); expect(result).toBe('Bingo!'); }); @@ -105,7 +105,7 @@ describe(`DSONameService`, () => { const result = service.getName(mockDSO); - expect((service as any).factories.Default).toHaveBeenCalledWith(mockDSO); + expect((service as any).factories.Default).toHaveBeenCalledWith(mockDSO, undefined); expect(result).toBe('Bingo!'); }); }); @@ -119,9 +119,9 @@ describe(`DSONameService`, () => { it(`should return 'person.familyName, person.givenName'`, () => { const result = (service as any).factories.Person(mockPerson); expect(result).toBe(mockPersonName); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName'); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName'); - expect(mockPerson.firstMetadataValue).not.toHaveBeenCalledWith('dc.title'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName', undefined, undefined); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName', undefined, undefined); + expect(mockPerson.firstMetadataValue).not.toHaveBeenCalledWith('dc.title', undefined, undefined); }); }); @@ -133,9 +133,9 @@ describe(`DSONameService`, () => { it(`should return dc.title`, () => { const result = (service as any).factories.Person(mockPerson); expect(result).toBe(mockPersonName); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName'); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName'); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('dc.title'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName', undefined, undefined); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName', undefined, undefined); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('dc.title', undefined, undefined); }); }); }); @@ -149,8 +149,8 @@ describe(`DSONameService`, () => { it(`should return 'eperson.firstname' and 'eperson.lastname'`, () => { const result = (service as any).factories.EPerson(mockEPerson); expect(result).toBe(mockEPersonName); - expect(mockEPerson.firstMetadataValue).toHaveBeenCalledWith('eperson.firstname'); - expect(mockEPerson.firstMetadataValue).toHaveBeenCalledWith('eperson.lastname'); + expect(mockEPerson.firstMetadataValue).toHaveBeenCalledWith('eperson.firstname', undefined, undefined); + expect(mockEPerson.firstMetadataValue).toHaveBeenCalledWith('eperson.lastname', undefined, undefined); }); }); @@ -162,8 +162,8 @@ describe(`DSONameService`, () => { it(`should return 'eperson.firstname'`, () => { const result = (service as any).factories.EPerson(mockEPersonFirst); expect(result).toBe(mockEPersonNameFirst); - expect(mockEPersonFirst.firstMetadataValue).toHaveBeenCalledWith('eperson.firstname'); - expect(mockEPersonFirst.firstMetadataValue).toHaveBeenCalledWith('eperson.lastname'); + expect(mockEPersonFirst.firstMetadataValue).toHaveBeenCalledWith('eperson.firstname', undefined, undefined); + expect(mockEPersonFirst.firstMetadataValue).toHaveBeenCalledWith('eperson.lastname', undefined, undefined); }); }); }); @@ -177,7 +177,7 @@ describe(`DSONameService`, () => { it(`should return 'organization.legalName'`, () => { const result = (service as any).factories.OrgUnit(mockOrgUnit); expect(result).toBe(mockOrgUnitName); - expect(mockOrgUnit.firstMetadataValue).toHaveBeenCalledWith('organization.legalName'); + expect(mockOrgUnit.firstMetadataValue).toHaveBeenCalledWith('organization.legalName', undefined, undefined); }); }); @@ -189,7 +189,7 @@ describe(`DSONameService`, () => { it(`should return 'dc.title'`, () => { const result = (service as any).factories.Default(mockDSO); expect(result).toBe(mockDSOName); - expect(mockDSO.firstMetadataValue).toHaveBeenCalledWith('dc.title'); + expect(mockDSO.firstMetadataValue).toHaveBeenCalledWith('dc.title', undefined, undefined); }); }); }); diff --git a/src/app/core/breadcrumbs/dso-name.service.ts b/src/app/core/breadcrumbs/dso-name.service.ts index 988141209f4..b7daa8dd4e2 100644 --- a/src/app/core/breadcrumbs/dso-name.service.ts +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -31,9 +31,9 @@ export class DSONameService { * With only two exceptions those solutions seem overkill for now. */ private readonly factories = { - EPerson: (dso: DSpaceObject): string => { - const firstName = dso.firstMetadataValue('eperson.firstname'); - const lastName = dso.firstMetadataValue('eperson.lastname'); + EPerson: (dso: DSpaceObject, escapeHTML?: boolean): string => { + const firstName = dso.firstMetadataValue('eperson.firstname', undefined, escapeHTML); + const lastName = dso.firstMetadataValue('eperson.lastname', undefined, escapeHTML); if (isEmpty(firstName) && isEmpty(lastName)) { return this.translateService.instant('dso.name.unnamed'); } else if (isEmpty(firstName) || isEmpty(lastName)) { @@ -42,23 +42,23 @@ export class DSONameService { return `${firstName} ${lastName}`; } }, - Person: (dso: DSpaceObject): string => { - const familyName = dso.firstMetadataValue('person.familyName'); - const givenName = dso.firstMetadataValue('person.givenName'); + Person: (dso: DSpaceObject, escapeHTML?: boolean): string => { + const familyName = dso.firstMetadataValue('person.familyName', undefined, escapeHTML); + const givenName = dso.firstMetadataValue('person.givenName', undefined, escapeHTML); if (isEmpty(familyName) && isEmpty(givenName)) { - return dso.firstMetadataValue('dc.title') || this.translateService.instant('dso.name.unnamed'); + return dso.firstMetadataValue('dc.title', undefined, escapeHTML) || this.translateService.instant('dso.name.unnamed'); } else if (isEmpty(familyName) || isEmpty(givenName)) { return familyName || givenName; } else { return `${familyName}, ${givenName}`; } }, - OrgUnit: (dso: DSpaceObject): string => { - return dso.firstMetadataValue('organization.legalName') || this.translateService.instant('dso.name.untitled'); + OrgUnit: (dso: DSpaceObject, escapeHTML?: boolean): string => { + return dso.firstMetadataValue('organization.legalName', undefined, escapeHTML); }, - Default: (dso: DSpaceObject): string => { + Default: (dso: DSpaceObject, escapeHTML?: boolean): string => { // If object doesn't have dc.title metadata use name property - return dso.firstMetadataValue('dc.title') || dso.name || this.translateService.instant('dso.name.untitled'); + return dso.firstMetadataValue('dc.title', undefined, escapeHTML) || dso.name || this.translateService.instant('dso.name.untitled'); }, }; @@ -66,8 +66,9 @@ export class DSONameService { * Get the name for the given {@link DSpaceObject} * * @param dso The {@link DSpaceObject} you want a name for + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute */ - getName(dso: DSpaceObject | undefined): string { + getName(dso: DSpaceObject | undefined, escapeHTML?: boolean): string { if (dso) { const types = dso.getRenderTypes(); const match = types @@ -76,10 +77,10 @@ export class DSONameService { let name; if (hasValue(match)) { - name = this.factories[match](dso); + name = this.factories[match](dso, escapeHTML); } if (isEmpty(name)) { - name = this.factories.Default(dso); + name = this.factories.Default(dso, escapeHTML); } return name; } else { @@ -92,27 +93,28 @@ export class DSONameService { * * @param object * @param dso + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * * @returns {string} html embedded hit highlight. */ - getHitHighlights(object: any, dso: DSpaceObject): string { + getHitHighlights(object: any, dso: DSpaceObject, escapeHTML?: boolean): string { const types = dso.getRenderTypes(); const entityType = types .filter((type) => typeof type === 'string') .find((type: string) => (['Person', 'OrgUnit']).includes(type)) as string; if (entityType === 'Person') { - const familyName = this.firstMetadataValue(object, dso, 'person.familyName'); - const givenName = this.firstMetadataValue(object, dso, 'person.givenName'); + const familyName = this.firstMetadataValue(object, dso, 'person.familyName', escapeHTML); + const givenName = this.firstMetadataValue(object, dso, 'person.givenName', escapeHTML); if (isEmpty(familyName) && isEmpty(givenName)) { - return this.firstMetadataValue(object, dso, 'dc.title') || dso.name; + return this.firstMetadataValue(object, dso, 'dc.title', escapeHTML) || dso.name; } else if (isEmpty(familyName) || isEmpty(givenName)) { return familyName || givenName; } return `${familyName}, ${givenName}`; } else if (entityType === 'OrgUnit') { - return this.firstMetadataValue(object, dso, 'organization.legalName') || this.translateService.instant('dso.name.untitled'); + return this.firstMetadataValue(object, dso, 'organization.legalName', escapeHTML); } - return this.firstMetadataValue(object, dso, 'dc.title') || dso.name || this.translateService.instant('dso.name.untitled'); + return this.firstMetadataValue(object, dso, 'dc.title', escapeHTML) || dso.name || this.translateService.instant('dso.name.untitled'); } /** @@ -121,11 +123,12 @@ export class DSONameService { * @param object * @param dso * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * * @returns {string} the first matching string value, or `undefined`. */ - firstMetadataValue(object: any, dso: DSpaceObject, keyOrKeys: string | string[]): string { - return Metadata.firstValue([object.hitHighlights, dso.metadata], keyOrKeys); + firstMetadataValue(object: any, dso: DSpaceObject, keyOrKeys: string | string[], escapeHTML?: boolean): string { + return Metadata.firstValue(dso.metadata, keyOrKeys, object.hitHighlights, undefined, escapeHTML); } } diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index b2d5476d21a..ee6a5677da0 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -77,11 +77,11 @@ export class CollectionDataService extends ComColDataService { * requested after the response becomes stale * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved + * @param searchHref The backend search endpoint to use (default to submit) * @return Observable>> * collection list */ - getAuthorizedCollection(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - const searchHref = 'findSubmitAuthorized'; + getAuthorizedCollection(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, searchHref: string = 'findSubmitAuthorized', ...linksToFollow: FollowLinkConfig[]): Observable>> { options = Object.assign({}, options, { searchParams: [new RequestParam('query', query)], }); diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 79dedf0c842..ba4feedbb55 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -11,9 +11,11 @@ import { isNotEmpty } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; 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 { Community } from '../shared/community.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getAllCompletedRemoteData } from '../shared/operators'; import { BitstreamDataService } from './bitstream-data.service'; import { ComColDataService } from './comcol-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; @@ -38,6 +40,32 @@ export class CommunityDataService extends ComColDataService { super('communities', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } + /** + * Get all communities the user is authorized to submit to + * + * @param query limit the returned community to those with metadata values + * matching the query terms. + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return Observable>> + * community list + */ + getAuthorizedCommunity(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchHref = 'findAdminAuthorized'; + options = Object.assign({}, options, { + searchParams: [new RequestParam('query', query)], + }); + + return this.searchBy(searchHref, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( + getAllCompletedRemoteData(), + ); + } + // this method is overridden in order to make it public getEndpoint() { return this.halService.getEndpoint(this.linkPath); diff --git a/src/app/core/data/version-history-data.service.ts b/src/app/core/data/version-history-data.service.ts index e15fcd39077..ea27928f499 100644 --- a/src/app/core/data/version-history-data.service.ts +++ b/src/app/core/data/version-history-data.service.ts @@ -111,7 +111,7 @@ export class VersionHistoryDataService extends IdentifiableDataService (summary?.length > 0) ? `${endpointUrl}?summary=${summary}` : `${endpointUrl}`), + map((endpointUrl: string) => (summary?.length > 0) ? `${endpointUrl}?summary=${encodeURIComponent(summary)}` : `${endpointUrl}`), find((href: string) => hasValue(href)), ).subscribe((href) => { const request = new PostRequest(requestId, href, itemHref, requestOptions); diff --git a/src/app/core/locale/locale.interceptor.spec.ts b/src/app/core/locale/locale.interceptor.spec.ts index 4725820a6b4..dcc4b7e257c 100644 --- a/src/app/core/locale/locale.interceptor.spec.ts +++ b/src/app/core/locale/locale.interceptor.spec.ts @@ -8,6 +8,7 @@ import { of } from 'rxjs'; import { RestRequestMethod } from '../data/rest-request-method'; import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { LocaleInterceptor } from './locale.interceptor'; import { LocaleService } from './locale.service'; @@ -23,6 +24,10 @@ describe(`LocaleInterceptor`, () => { getLanguageCodeList: of(languageList), }); + const mockHalEndpointService = { + getRootHref: jasmine.createSpy('getRootHref'), + }; + beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], @@ -33,6 +38,7 @@ describe(`LocaleInterceptor`, () => { useClass: LocaleInterceptor, multi: true, }, + { provide: HALEndpointService, useValue: mockHalEndpointService }, { provide: LocaleService, useValue: mockLocaleService }, ], }); @@ -41,7 +47,7 @@ describe(`LocaleInterceptor`, () => { httpMock = TestBed.inject(HttpTestingController); localeService = TestBed.inject(LocaleService); - localeService.getCurrentLanguageCode.and.returnValue('en'); + localeService.getCurrentLanguageCode.and.returnValue(of('en')); }); describe('', () => { diff --git a/src/app/core/locale/locale.interceptor.ts b/src/app/core/locale/locale.interceptor.ts index 6dfa19485d9..a415ab8c514 100644 --- a/src/app/core/locale/locale.interceptor.ts +++ b/src/app/core/locale/locale.interceptor.ts @@ -9,14 +9,19 @@ import { Observable } from 'rxjs'; import { mergeMap, scan, + take, } from 'rxjs/operators'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { LocaleService } from './locale.service'; @Injectable() export class LocaleInterceptor implements HttpInterceptor { - constructor(private localeService: LocaleService) { + constructor( + protected halEndpointService: HALEndpointService, + protected localeService: LocaleService, + ) { } /** @@ -26,8 +31,9 @@ export class LocaleInterceptor implements HttpInterceptor { */ intercept(req: HttpRequest, next: HttpHandler): Observable> { let newReq: HttpRequest; - return this.localeService.getLanguageCodeList() + return this.localeService.getLanguageCodeList(req.url === this.halEndpointService.getRootHref()) .pipe( + take(1), scan((acc: any, value: any) => [...acc, value], []), mergeMap((languages) => { // Clone the request to add the new header. diff --git a/src/app/core/locale/locale.service.spec.ts b/src/app/core/locale/locale.service.spec.ts index d7f681056cb..2b2ef9eb205 100644 --- a/src/app/core/locale/locale.service.spec.ts +++ b/src/app/core/locale/locale.service.spec.ts @@ -7,9 +7,12 @@ import { TranslateModule, TranslateService, } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { EPersonMock2 } from '../../shared/testing/eperson.mock'; import { routeServiceStub } from '../../shared/testing/route-service.stub'; import { AuthService } from '../auth/auth.service'; import { CookieService } from '../services/cookie.service'; @@ -36,6 +39,7 @@ describe('LocaleService test suite', () => { authService = jasmine.createSpyObj('AuthService', { isAuthenticated: jasmine.createSpy('isAuthenticated'), isAuthenticationLoaded: jasmine.createSpy('isAuthenticationLoaded'), + getAuthenticatedUserFromStore: jasmine.createSpy('getAuthenticatedUserFromStore'), }); const langList = ['en', 'xx', 'de']; @@ -72,33 +76,80 @@ describe('LocaleService test suite', () => { }); describe('getCurrentLanguageCode', () => { + let testScheduler: TestScheduler; + beforeEach(() => { spyOn(translateService, 'getLangs').and.returnValue(langList); + testScheduler = new TestScheduler((actual, expected) => { + // use jasmine to test equality + expect(actual).toEqual(expected); + }); + authService.isAuthenticated.and.returnValue(of(false)); + authService.isAuthenticationLoaded.and.returnValue(of(false)); }); it('should return the language saved on cookie if it\'s a valid & active language', () => { spyOnGet.and.returnValue('de'); - expect(service.getCurrentLanguageCode()).toBe('de'); + testScheduler.run(({ expectObservable }) => { + expectObservable(service.getCurrentLanguageCode()).toBe('(a|)', { a: 'de' }); + }); }); it('should return the default language if the cookie language is disabled', () => { spyOnGet.and.returnValue('disabled'); - expect(service.getCurrentLanguageCode()).toBe('en'); + testScheduler.run(({ expectObservable }) => { + expectObservable(service.getCurrentLanguageCode()).toBe('(a|)', { a: 'en' }); + }); }); it('should return the default language if the cookie language does not exist', () => { spyOnGet.and.returnValue('does-not-exist'); - expect(service.getCurrentLanguageCode()).toBe('en'); + testScheduler.run(({ expectObservable }) => { + expectObservable(service.getCurrentLanguageCode()).toBe('(a|)', { a: 'en' }); + }); }); it('should return language from browser setting', () => { - spyOn(translateService, 'getBrowserLang').and.returnValue('xx'); - expect(service.getCurrentLanguageCode()).toBe('xx'); + spyOn(service, 'getLanguageCodeList').and.returnValue(of(['xx', 'en'])); + testScheduler.run(({ expectObservable }) => { + expectObservable(service.getCurrentLanguageCode()).toBe('(a|)', { a: 'xx' }); + }); + }); + + it('should match language from browser setting case insensitive', () => { + spyOn(service, 'getLanguageCodeList').and.returnValue(of(['DE', 'en'])); + testScheduler.run(({ expectObservable }) => { + expectObservable(service.getCurrentLanguageCode()).toBe('(a|)', { a: 'DE' }); + }); + }); + }); + + describe('getLanguageCodeList', () => { + let testScheduler: TestScheduler; + + beforeEach(() => { + spyOn(translateService, 'getLangs').and.returnValue(langList); + testScheduler = new TestScheduler((actual, expected) => { + // use jasmine to test equality + expect(actual).toEqual(expected); + }); + }); + + it('should return default language list without user preferred language when no logged in user', () => { + authService.isAuthenticated.and.returnValue(of(false)); + authService.isAuthenticationLoaded.and.returnValue(of(false)); + testScheduler.run(({ expectObservable }) => { + expectObservable(service.getLanguageCodeList()).toBe('(a|)', { a: ['en-US;q=1', 'en;q=0.9'] }); + }); }); - it('should return default language from config', () => { - spyOn(translateService, 'getBrowserLang').and.returnValue('fr'); - expect(service.getCurrentLanguageCode()).toBe('en'); + it('should return default language list with user preferred language when user is logged in', () => { + authService.isAuthenticated.and.returnValue(of(true)); + authService.isAuthenticationLoaded.and.returnValue(of(true)); + authService.getAuthenticatedUserFromStore.and.returnValue(of(EPersonMock2)); + testScheduler.run(({ expectObservable }) => { + expectObservable(service.getLanguageCodeList()).toBe('(a|)', { a: ['fr;q=0.5', 'en-US;q=1', 'en;q=0.9'] }); + }); }); }); @@ -130,14 +181,13 @@ describe('LocaleService test suite', () => { }); it('should set the current language', () => { - spyOn(service, 'getCurrentLanguageCode').and.returnValue('es'); + spyOn(service, 'getCurrentLanguageCode').and.returnValue(of('es')); service.setCurrentLanguageCode(); expect(translateService.use).toHaveBeenCalledWith('es'); - expect(service.saveLanguageCodeToCookie).toHaveBeenCalledWith('es'); }); it('should set the current language on the html tag', () => { - spyOn(service, 'getCurrentLanguageCode').and.returnValue('es'); + spyOn(service, 'getCurrentLanguageCode').and.returnValue(of('es')); service.setCurrentLanguageCode(); expect((service as any).document.documentElement.lang).toEqual('es'); }); diff --git a/src/app/core/locale/locale.service.ts b/src/app/core/locale/locale.service.ts index 0c54ce8412b..3c20f0e0cde 100644 --- a/src/app/core/locale/locale.service.ts +++ b/src/app/core/locale/locale.service.ts @@ -2,12 +2,14 @@ import { DOCUMENT } from '@angular/common'; import { Inject, Injectable, + OnDestroy, } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { combineLatest, Observable, of as observableOf, + Subscription, } from 'rxjs'; import { map, @@ -18,6 +20,7 @@ import { import { LangConfig } from '../../../config/lang-config.interface'; import { environment } from '../../../environments/environment'; import { + hasValue, isEmpty, isNotEmpty, } from '../../shared/empty.util'; @@ -44,13 +47,15 @@ export enum LANG_ORIGIN { * Service to provide localization handler */ @Injectable() -export class LocaleService { +export class LocaleService implements OnDestroy { /** * Eperson language metadata */ EPERSON_LANG_METADATA = 'eperson.language'; + subs: Subscription[] = []; + constructor( @Inject(NativeWindowService) protected _window: NativeWindowRef, protected cookie: CookieService, @@ -64,20 +69,25 @@ export class LocaleService { /** * Get the language currently used * - * @returns {string} The language code + * @returns {Observable} The language code */ - getCurrentLanguageCode(): string { + getCurrentLanguageCode(): Observable { // Attempt to get the language from a cookie - let lang = this.getLanguageCodeFromCookie(); + const lang = this.getLanguageCodeFromCookie(); if (isEmpty(lang) || environment.languages.find((langConfig: LangConfig) => langConfig.code === lang && langConfig.active) === undefined) { // Attempt to get the browser language from the user - if (this.translate.getLangs().includes(this.translate.getBrowserLang())) { - lang = this.translate.getBrowserLang(); - } else { - lang = environment.defaultLanguage; - } + return this.getLanguageCodeList() + .pipe( + map(browserLangs => { + return browserLangs + .map(browserLang => browserLang.split(';')[0]) + .find(browserLang => + this.translate.getLangs().some(userLang => userLang.toLowerCase() === browserLang.toLowerCase()), + ) || environment.defaultLanguage; + }), + ); } - return lang; + return observableOf(lang); } /** @@ -85,18 +95,16 @@ export class LocaleService { * * @returns {Observable} */ - getLanguageCodeList(): Observable { + getLanguageCodeList(ignoreEPersonSettings = false): Observable { const obs$ = combineLatest([ this.authService.isAuthenticated(), this.authService.isAuthenticationLoaded(), ]); return obs$.pipe( - take(1), mergeMap(([isAuthenticated, isLoaded]) => { - // TODO to enabled again when https://github.com/DSpace/dspace-angular/issues/739 will be resolved - const epersonLang$: Observable = observableOf([]); - /* if (isAuthenticated && isLoaded) { + let epersonLang$: Observable = observableOf([]); + if (isAuthenticated && isLoaded && !ignoreEPersonSettings) { epersonLang$ = this.authService.getAuthenticatedUserFromStore().pipe( take(1), map((eperson) => { @@ -109,21 +117,21 @@ export class LocaleService { !isEmpty(this.translate.currentLang))); } return languages; - }) + }), ); - }*/ + } return epersonLang$.pipe( map((epersonLang: string[]) => { const languages: string[] = []; + if (isNotEmpty(epersonLang)) { + languages.push(...epersonLang); + } if (this.translate.currentLang) { languages.push(...this.setQuality( [this.translate.currentLang], LANG_ORIGIN.UI, false)); } - if (isNotEmpty(epersonLang)) { - languages.push(...epersonLang); - } if (navigator.languages) { languages.push(...this.setQuality( Object.assign([], navigator.languages), @@ -163,11 +171,16 @@ export class LocaleService { */ setCurrentLanguageCode(lang?: string): void { if (isEmpty(lang)) { - lang = this.getCurrentLanguageCode(); + this.subs.push(this.getCurrentLanguageCode().subscribe(curLang => { + lang = curLang; + this.translate.use(lang); + this.document.documentElement.lang = lang; + })); + } else { + this.saveLanguageCodeToCookie(lang); + this.translate.use(lang); + this.document.documentElement.lang = lang; } - this.translate.use(lang); - this.saveLanguageCodeToCookie(lang); - this.document.documentElement.lang = lang; } /** @@ -213,4 +226,10 @@ export class LocaleService { } + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + } diff --git a/src/app/core/locale/server-locale.service.ts b/src/app/core/locale/server-locale.service.ts index 12358595d1a..da48d9a20a6 100644 --- a/src/app/core/locale/server-locale.service.ts +++ b/src/app/core/locale/server-locale.service.ts @@ -53,7 +53,7 @@ export class ServerLocaleService extends LocaleService { * * @returns {Observable} */ - getLanguageCodeList(): Observable { + getLanguageCodeList(ignoreEPersonSettings = false): Observable { const obs$ = combineLatest([ this.authService.isAuthenticated(), this.authService.isAuthenticationLoaded(), @@ -63,7 +63,7 @@ export class ServerLocaleService extends LocaleService { take(1), mergeMap(([isAuthenticated, isLoaded]) => { let epersonLang$: Observable = observableOf([]); - if (isAuthenticated && isLoaded) { + if (isAuthenticated && isLoaded && !ignoreEPersonSettings) { epersonLang$ = this.authService.getAuthenticatedUserFromStore().pipe( take(1), map((eperson) => { diff --git a/src/app/core/metadata/head-tag.service.spec.ts b/src/app/core/metadata/head-tag.service.spec.ts index 2fbae88f120..da4fa97ee98 100644 --- a/src/app/core/metadata/head-tag.service.spec.ts +++ b/src/app/core/metadata/head-tag.service.spec.ts @@ -24,6 +24,7 @@ import { MockBitstream1, MockBitstream2, MockBitstream3, + NonDiscoverableItemMock, } from '../../shared/mocks/item.mock'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { @@ -128,6 +129,37 @@ describe('HeadTagService', () => { ); }); + describe(`robots tag`, () => { + it(`should be set to noindex for non-discoverable items`, fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(NonDiscoverableItemMock), + }, + }, + }); + tick(); + expect(meta.addTag).toHaveBeenCalledWith({ + name: 'robots', + content: 'noindex', + }); + })); + it(`should not be set for discoverable items`, fakeAsync(() => { + (headTagService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + }, + }, + }); + tick(); + expect(meta.addTag).not.toHaveBeenCalledWith({ + name: 'robots', + content: 'noindex', + }); + })); + }); + it('items page should set meta tags', fakeAsync(() => { (headTagService as any).processRouteChange({ data: { diff --git a/src/app/core/metadata/head-tag.service.ts b/src/app/core/metadata/head-tag.service.ts index d52efb8fa12..efe585733b0 100644 --- a/src/app/core/metadata/head-tag.service.ts +++ b/src/app/core/metadata/head-tag.service.ts @@ -173,6 +173,8 @@ export class HeadTagService { protected setDSOMetaTags(): void { + this.setNoIndexTag(); + this.setTitleTag(); this.setDescriptionTag(); @@ -210,6 +212,15 @@ export class HeadTagService { } + /** + * Add to the if non-discoverable item + */ + protected setNoIndexTag(): void { + if (this.currentObject.value instanceof Item && this.currentObject.value.isDiscoverable === false) { + this.addMetaTag('robots', 'noindex'); + } + } + /** * Add to the */ diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 7bc05b1d3aa..6ed3ea9105a 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -118,33 +118,36 @@ export class DSpaceObject extends ListableObject implements CacheableObject { * Gets all matching metadata in this DSpaceObject. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. - * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param {MetadataValueFilter} valueFilter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {MetadataValue[]} the matching values or an empty array. */ - allMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): MetadataValue[] { - return Metadata.all(this.metadata, keyOrKeys, valueFilter); + allMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter, escapeHTML?: boolean): MetadataValue[] { + return Metadata.all(this.metadata, keyOrKeys, undefined, valueFilter, escapeHTML); } /** * Like [[allMetadata]], but only returns string values. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. - * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param {MetadataValueFilter} valueFilter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {string[]} the matching string values or an empty array. */ - allMetadataValues(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string[] { - return Metadata.allValues(this.metadata, keyOrKeys, valueFilter); + allMetadataValues(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter, escapeHTML?: boolean): string[] { + return Metadata.allValues(this.metadata, keyOrKeys, undefined, valueFilter, escapeHTML); } /** * Gets the first matching MetadataValue object in this DSpaceObject, or `undefined`. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. - * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param {MetadataValueFilter} valueFilter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {MetadataValue} the first matching value, or `undefined`. */ - firstMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): MetadataValue { - return Metadata.first(this.metadata, keyOrKeys, valueFilter); + firstMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter, escapeHTML?: boolean): MetadataValue { + return Metadata.first(this.metadata, keyOrKeys, undefined, valueFilter, escapeHTML); } /** @@ -152,26 +155,27 @@ export class DSpaceObject extends ListableObject implements CacheableObject { * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. * @param {MetadataValueFilter} valueFilter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {string} the first matching string value, or `undefined`. */ - firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string { - return Metadata.firstValue(this.metadata, keyOrKeys, valueFilter); + firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter, escapeHTML?: boolean): string { + return Metadata.firstValue(this.metadata, keyOrKeys, undefined, valueFilter, escapeHTML); } /** * Checks for a matching metadata value in this DSpaceObject. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. - * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param {MetadataValueFilter} valueFilter The value filter to use. If unspecified, no filtering will be done. * @returns {boolean} whether a match is found. */ hasMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): boolean { - return Metadata.has(this.metadata, keyOrKeys, valueFilter); + return Metadata.has(this.metadata, keyOrKeys, undefined, valueFilter); } /** * Find metadata on a specific field and order all of them using their "place" property. - * @param key + * @param keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. */ findMetadataSortedByPlace(keyOrKeys: string | string[]): MetadataValue[] { return this.allMetadata(keyOrKeys).sort((a: MetadataValue, b: MetadataValue) => { diff --git a/src/app/core/shared/metadata.utils.spec.ts b/src/app/core/shared/metadata.utils.spec.ts index 2ba96201b02..55fbbc78d5f 100644 --- a/src/app/core/shared/metadata.utils.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -50,11 +50,11 @@ const multiViewModelList = [ { key: 'foo', ...bar, order: 0 }, ]; -const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, expected, filter?) => { +const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, hitHighlights, expected, filter?) => { const keys = keyOrKeys instanceof Array ? keyOrKeys : [keyOrKeys]; describe('and key' + (keys.length === 1 ? (' ' + keys[0]) : ('s ' + JSON.stringify(keys))) + ' with ' + (isUndefined(filter) ? 'no filter' : 'filter ' + JSON.stringify(filter)), () => { - const result = fn(mapOrMaps, keys, filter); + const result = fn(mapOrMaps, keys, hitHighlights, filter); let shouldReturn; if (resultKind === 'boolean') { shouldReturn = expected; @@ -76,107 +76,107 @@ describe('Metadata', () => { describe('all method', () => { - const testAll = (mapOrMaps, keyOrKeys, expected, filter?: MetadataValueFilter) => - testMethod(Metadata.all, 'value', mapOrMaps, keyOrKeys, expected, filter); + const testAll = (mapOrMaps, keyOrKeys, hitHighlights, expected, filter?: MetadataValueFilter) => + testMethod(Metadata.all, 'value', mapOrMaps, keyOrKeys, hitHighlights, expected, filter); describe('with emptyMap', () => { - testAll({}, 'foo', []); - testAll({}, '*', []); + testAll({}, 'foo', undefined, []); + testAll({}, '*', undefined, []); }); describe('with singleMap', () => { - testAll(singleMap, 'foo', []); - testAll(singleMap, '*', [dcTitle0]); - testAll(singleMap, '*', [], { value: 'baz' }); - testAll(singleMap, 'dc.title', [dcTitle0]); - testAll(singleMap, 'dc.*', [dcTitle0]); + testAll(singleMap, 'foo', undefined, []); + testAll(singleMap, '*', undefined, [dcTitle0]); + testAll(singleMap, '*', undefined, [], { value: 'baz' }); + testAll(singleMap, 'dc.title', undefined, [dcTitle0]); + testAll(singleMap, 'dc.*', undefined, [dcTitle0]); }); describe('with multiMap', () => { - testAll(multiMap, 'foo', [bar]); - testAll(multiMap, '*', [dcDescription, dcAbstract, dcTitle1, dcTitle2, bar]); - testAll(multiMap, 'dc.title', [dcTitle1, dcTitle2]); - testAll(multiMap, 'dc.*', [dcDescription, dcAbstract, dcTitle1, dcTitle2]); - testAll(multiMap, ['dc.title', 'dc.*'], [dcTitle1, dcTitle2, dcDescription, dcAbstract]); + testAll(multiMap, 'foo', undefined, [bar]); + testAll(multiMap, '*', undefined, [dcDescription, dcAbstract, dcTitle1, dcTitle2, bar]); + testAll(multiMap, 'dc.title', undefined, [dcTitle1, dcTitle2]); + testAll(multiMap, 'dc.*', undefined, [dcDescription, dcAbstract, dcTitle1, dcTitle2]); + testAll(multiMap, ['dc.title', 'dc.*'], undefined, [dcTitle1, dcTitle2, dcDescription, dcAbstract]); }); describe('with [ singleMap, multiMap ]', () => { - testAll([singleMap, multiMap], 'foo', [bar]); - testAll([singleMap, multiMap], '*', [dcTitle0]); - testAll([singleMap, multiMap], 'dc.title', [dcTitle0]); - testAll([singleMap, multiMap], 'dc.*', [dcTitle0]); + testAll(multiMap, 'foo', singleMap, [bar]); + testAll(multiMap, '*', singleMap, [dcTitle0]); + testAll(multiMap, 'dc.title', singleMap, [dcTitle0]); + testAll(multiMap, 'dc.*', singleMap, [dcTitle0]); }); describe('with [ multiMap, singleMap ]', () => { - testAll([multiMap, singleMap], 'foo', [bar]); - testAll([multiMap, singleMap], '*', [dcDescription, dcAbstract, dcTitle1, dcTitle2, bar]); - testAll([multiMap, singleMap], 'dc.title', [dcTitle1, dcTitle2]); - testAll([multiMap, singleMap], 'dc.*', [dcDescription, dcAbstract, dcTitle1, dcTitle2]); - testAll([multiMap, singleMap], ['dc.title', 'dc.*'], [dcTitle1, dcTitle2, dcDescription, dcAbstract]); + testAll(singleMap, 'foo', multiMap, [bar]); + testAll(singleMap, '*', multiMap, [dcDescription, dcAbstract, dcTitle1, dcTitle2, bar]); + testAll(singleMap, 'dc.title', multiMap, [dcTitle1, dcTitle2]); + testAll(singleMap, 'dc.*', multiMap, [dcDescription, dcAbstract, dcTitle1, dcTitle2]); + testAll(singleMap, ['dc.title', 'dc.*'], multiMap, [dcTitle1, dcTitle2, dcDescription, dcAbstract]); }); describe('with regexTestMap', () => { - testAll(regexTestMap, 'foo.bar.*', []); + testAll(regexTestMap, 'foo.bar.*', undefined, []); }); }); describe('allValues method', () => { - const testAllValues = (mapOrMaps, keyOrKeys, expected) => - testMethod(Metadata.allValues, 'string', mapOrMaps, keyOrKeys, expected); + const testAllValues = (mapOrMaps, keyOrKeys, hitHighlights, expected) => + testMethod(Metadata.allValues, 'string', mapOrMaps, keyOrKeys, hitHighlights, expected); describe('with emptyMap', () => { - testAllValues({}, '*', []); + testAllValues({}, '*', undefined, []); }); describe('with singleMap', () => { - testAllValues([singleMap, multiMap], '*', [dcTitle0.value]); + testAllValues(multiMap, '*', singleMap, [dcTitle0.value]); }); describe('with [ multiMap, singleMap ]', () => { - testAllValues([multiMap, singleMap], '*', [dcDescription.value, dcAbstract.value, dcTitle1.value, dcTitle2.value, bar.value]); + testAllValues(singleMap, '*', multiMap, [dcDescription.value, dcAbstract.value, dcTitle1.value, dcTitle2.value, bar.value]); }); }); describe('first method', () => { - const testFirst = (mapOrMaps, keyOrKeys, expected) => - testMethod(Metadata.first, 'value', mapOrMaps, keyOrKeys, expected); + const testFirst = (mapOrMaps, keyOrKeys, hitHighlights, expected) => + testMethod(Metadata.first, 'value', mapOrMaps, keyOrKeys, hitHighlights, expected); describe('with emptyMap', () => { - testFirst({}, '*', undefined); + testFirst({}, '*', undefined, undefined); }); describe('with singleMap', () => { - testFirst(singleMap, '*', dcTitle0); + testFirst(singleMap, '*', undefined, dcTitle0); }); describe('with [ multiMap, singleMap ]', () => { - testFirst([multiMap, singleMap], '*', dcDescription); + testFirst(singleMap, '*', multiMap, dcDescription); }); }); describe('firstValue method', () => { - const testFirstValue = (mapOrMaps, keyOrKeys, expected) => - testMethod(Metadata.firstValue, 'value', mapOrMaps, keyOrKeys, expected); + const testFirstValue = (mapOrMaps, keyOrKeys, hitHighlights, expected) => + testMethod(Metadata.firstValue, 'value', mapOrMaps, keyOrKeys, hitHighlights, expected); describe('with emptyMap', () => { - testFirstValue({}, '*', undefined); + testFirstValue({}, '*', undefined, undefined); }); describe('with singleMap', () => { - testFirstValue(singleMap, '*', dcTitle0.value); + testFirstValue(singleMap, '*', undefined, dcTitle0.value); }); describe('with [ multiMap, singleMap ]', () => { - testFirstValue([multiMap, singleMap], '*', dcDescription.value); + testFirstValue(singleMap, '*', multiMap, dcDescription.value); }); }); describe('has method', () => { - const testHas = (mapOrMaps, keyOrKeys, expected, filter?: MetadataValueFilter) => - testMethod(Metadata.has, 'boolean', mapOrMaps, keyOrKeys, expected, filter); + const testHas = (mapOrMaps, keyOrKeys, hitHighlights, expected, filter?: MetadataValueFilter) => + testMethod(Metadata.has, 'boolean', mapOrMaps, keyOrKeys, hitHighlights, expected, filter); describe('with emptyMap', () => { - testHas({}, '*', false); + testHas({}, '*', undefined, false); }); describe('with singleMap', () => { - testHas(singleMap, '*', true); - testHas(singleMap, '*', false, { value: 'baz' }); + testHas(singleMap, '*', undefined, true); + testHas(singleMap, '*', undefined, false, { value: 'baz' }); }); describe('with [ multiMap, singleMap ]', () => { - testHas([multiMap, singleMap], '*', true); + testHas(singleMap, '*', multiMap, true); }); }); diff --git a/src/app/core/shared/metadata.utils.ts b/src/app/core/shared/metadata.utils.ts index f0290eac398..915ead48dd1 100644 --- a/src/app/core/shared/metadata.utils.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -1,8 +1,8 @@ +import escape from 'lodash/escape'; import groupBy from 'lodash/groupBy'; import sortBy from 'lodash/sortBy'; import { - isEmpty, isNotEmpty, isNotUndefined, isUndefined, @@ -32,94 +32,120 @@ export class Metadata { /** * Gets all matching metadata in the map(s). * - * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). When multiple maps are given, they will be - * checked in order, and only values from the first with at least one match will be returned. + * @param metadata The metadata values. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param hitHighlights The search hit highlights. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {MetadataValue[]} the matching values or an empty array. */ - public static all(mapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], - filter?: MetadataValueFilter): MetadataValue[] { - const mdMaps: MetadataMapInterface[] = mapOrMaps instanceof Array ? mapOrMaps : [mapOrMaps]; + public static all(metadata: MetadataMapInterface, keyOrKeys: string | string[], hitHighlights?: MetadataMapInterface, filter?: MetadataValueFilter, escapeHTML?: boolean): MetadataValue[] { const matches: MetadataValue[] = []; - for (const mdMap of mdMaps) { - for (const mdKey of Metadata.resolveKeys(mdMap, keyOrKeys)) { - const candidates = mdMap[mdKey]; - if (candidates) { - for (const candidate of candidates) { + if (isNotEmpty(hitHighlights)) { + for (const mdKey of Metadata.resolveKeys(hitHighlights, keyOrKeys)) { + if (hitHighlights[mdKey]) { + for (const candidate of hitHighlights[mdKey]) { if (Metadata.valueMatches(candidate as MetadataValue, filter)) { matches.push(candidate as MetadataValue); } } } } - if (!isEmpty(matches)) { + if (isNotEmpty(matches)) { return matches; } } + for (const mdKey of Metadata.resolveKeys(metadata, keyOrKeys)) { + if (metadata[mdKey]) { + for (const candidate of metadata[mdKey]) { + if (Metadata.valueMatches(candidate as MetadataValue, filter)) { + if (escapeHTML) { + matches.push(Object.assign(new MetadataValue(), candidate, { + value: escape(candidate.value), + })); + } else { + matches.push(candidate as MetadataValue); + } + } + } + } + } return matches; } /** * Like [[Metadata.all]], but only returns string values. * - * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). When multiple maps are given, they will be - * checked in order, and only values from the first with at least one match will be returned. + * @param metadata The metadata values. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param hitHighlights The search hit highlights. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {string[]} the matching string values or an empty array. */ - public static allValues(mapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], - filter?: MetadataValueFilter): string[] { - return Metadata.all(mapOrMaps, keyOrKeys, filter).map((mdValue) => mdValue.value); + public static allValues(metadata: MetadataMapInterface, keyOrKeys: string | string[], hitHighlights?: MetadataMapInterface, filter?: MetadataValueFilter, escapeHTML?: boolean): string[] { + return Metadata.all(metadata, keyOrKeys, hitHighlights, filter, escapeHTML).map((mdValue) => mdValue.value); } /** * Gets the first matching MetadataValue object in the map(s), or `undefined`. * - * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). + * @param metadata The metadata values. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param hitHighlights The search hit highlights. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {MetadataValue} the first matching value, or `undefined`. */ - public static first(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], - filter?: MetadataValueFilter): MetadataValue { - const mdMaps: MetadataMapInterface[] = mdMapOrMaps instanceof Array ? mdMapOrMaps : [mdMapOrMaps]; - for (const mdMap of mdMaps) { - for (const key of Metadata.resolveKeys(mdMap, keyOrKeys)) { - const values: MetadataValue[] = mdMap[key] as MetadataValue[]; + public static first(metadata: MetadataMapInterface, keyOrKeys: string | string[], hitHighlights?: MetadataMapInterface, filter?: MetadataValueFilter, escapeHTML?: boolean): MetadataValue { + if (isNotEmpty(hitHighlights)) { + for (const key of Metadata.resolveKeys(hitHighlights, keyOrKeys)) { + const values: MetadataValue[] = hitHighlights[key] as MetadataValue[]; if (values) { return values.find((value: MetadataValue) => Metadata.valueMatches(value, filter)); } } } + for (const key of Metadata.resolveKeys(metadata, keyOrKeys)) { + const values: MetadataValue[] = metadata[key] as MetadataValue[]; + if (values) { + const result: MetadataValue = values.find((value: MetadataValue) => Metadata.valueMatches(value, filter)); + if (escapeHTML) { + return Object.assign(new MetadataValue(), result, { + value: escape(result.value), + }); + } + return result; + } + } } /** * Like [[Metadata.first]], but only returns a string value, or `undefined`. * - * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). + * @param metadata The metadata values. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param hitHighlights The search hit highlights. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {string} the first matching string value, or `undefined`. */ - public static firstValue(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], - filter?: MetadataValueFilter): string { - const value = Metadata.first(mdMapOrMaps, keyOrKeys, filter); + public static firstValue(metadata: MetadataMapInterface, keyOrKeys: string | string[], hitHighlights?: MetadataMapInterface, filter?: MetadataValueFilter, escapeHTML?: boolean): string { + const value = Metadata.first(metadata, keyOrKeys, hitHighlights, filter, escapeHTML); return isUndefined(value) ? undefined : value.value; } /** * Checks for a matching metadata value in the given map(s). * - * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). + * @param metadata The metadata values. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. + * @param hitHighlights The search hit highlights. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. * @returns {boolean} whether a match is found. */ - public static has(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], - filter?: MetadataValueFilter): boolean { - return isNotUndefined(Metadata.first(mdMapOrMaps, keyOrKeys, filter)); + public static has(metadata: MetadataMapInterface, keyOrKeys: string | string[], hitHighlights?: MetadataMapInterface, filter?: MetadataValueFilter): boolean { + return isNotUndefined(Metadata.first(metadata, keyOrKeys, hitHighlights, filter)); } /** diff --git a/src/app/core/shared/search/search-configuration.service.ts b/src/app/core/shared/search/search-configuration.service.ts index b9e0207cc30..9b675d6b93b 100644 --- a/src/app/core/shared/search/search-configuration.service.ts +++ b/src/app/core/shared/search/search-configuration.service.ts @@ -183,7 +183,7 @@ export class SearchConfigurationService implements OnDestroy { */ getCurrentQuery(defaultQuery: string) { return this.routeService.getQueryParameterValue('query').pipe(map((query) => { - return query || defaultQuery; + return query !== null ? query : defaultQuery; // Allow querying when the value is empty })); } diff --git a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html index ec4dbd43236..c661c55bceb 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html +++ b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html @@ -1,12 +1,12 @@ - + + [ngbTooltip]="mdRepresentation.hasMetadata(['dc.description']) ? descTemplate : null"> diff --git a/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html b/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html index cbc68ef7cf9..32d410412fd 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html +++ b/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.html @@ -2,7 +2,7 @@ - + ; diff --git a/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.html b/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.html index acc9173bf7d..4c1f9266d6b 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.html +++ b/src/app/entity-groups/research-entities/metadata-representations/project/project-item-metadata-list-element.component.html @@ -1,12 +1,12 @@ - + - \ No newline at end of file + [innerHTML]="dsoNameService.getName(mdRepresentation, true)" + [ngbTooltip]="dsoNameService.getName(mdRepresentation, true).length > 0 ? descTemplate : null"> + diff --git a/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts b/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts index bc962e27ffe..cc3a3b6789a 100644 --- a/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts +++ b/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts @@ -148,7 +148,7 @@ export class ItemCollectionMapperComponent implements OnInit { this.itemName$ = this.itemRD$.pipe( filter((rd: RemoteData) => hasValue(rd)), map((rd: RemoteData) => { - return this.dsoNameService.getName(rd.payload); + return this.dsoNameService.getName(rd.payload, true); }), ); this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.ts index 0fef74b3896..2c2214e78b2 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.ts +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.ts @@ -141,6 +141,12 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { */ @Input() entityType: string; + /** + * Search endpoint to use for finding authorized collections. + * Defaults to 'findSubmitAuthorized', but can be overridden (e.g. to 'findAdminAuthorized') + */ + @Input() searchHref = 'findSubmitAuthorized'; + /** * Emit to notify whether search is complete */ @@ -249,7 +255,7 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { followLink('parentCommunity')); } else { searchListService$ = this.collectionDataService - .getAuthorizedCollection(query, findOptions, true, true, followLink('parentCommunity')); + .getAuthorizedCollection(query, findOptions, true, true, this.searchHref, followLink('parentCommunity')); } this.searchListCollection$ = searchListService$.pipe( getFirstCompletedRemoteData(), diff --git a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts index de9b0b00ef5..3155f30aac4 100644 --- a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts +++ b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts @@ -155,7 +155,7 @@ export class ComcolPageBrowseByComponent implements OnDestroy, OnInit { ]).subscribe(([navOptions, url]: [ComColPageNavOption[], string]) => { for (const option of navOptions) { if (url?.split('?')[0] === comColRoute && option.id === this.appConfig[this.contentType].defaultBrowseTab) { - void this.router.navigate([option.routerLink], { queryParams: option.params }); + void this.router.navigate([option.routerLink], { queryParams: option.params, replaceUrl: true }); break; } else if (option.routerLink === url?.split('?')[0]) { this.currentOption$.next(option); diff --git a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts index 1695d43f522..4a40f90f013 100644 --- a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts +++ b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.spec.ts @@ -83,5 +83,19 @@ describe('AuthorizedCollectionSelectorComponent', () => { }); }); }); + + describe('when using searchHref', () => { + it('should call getAuthorizedCollection with "findAdminAuthorized" when overridden', (done) => { + component.searchHref = 'findAdminAuthorized'; + + component.search('', 1).subscribe(() => { + expect(collectionService.getAuthorizedCollection).toHaveBeenCalledWith( + '', jasmine.any(Object), true, false, 'findAdminAuthorized', jasmine.anything(), + ); + done(); + }); + }); + }); + }); }); diff --git a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts index 7e8019a54b8..29f6422952d 100644 --- a/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts +++ b/src/app/shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component.ts @@ -58,6 +58,12 @@ export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent */ @Input() entityType: string; + /** + * Search endpoint to use for finding authorized collections. + * Defaults to 'findSubmitAuthorized', but can be overridden (e.g. to 'findAdminAuthorized') + */ + @Input() searchHref = 'findSubmitAuthorized'; + constructor( protected searchService: SearchService, protected collectionDataService: CollectionDataService, @@ -96,7 +102,7 @@ export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent findOptions); } else { searchListService$ = this.collectionDataService - .getAuthorizedCollection(query, findOptions, useCache, false, followLink('parentCommunity')); + .getAuthorizedCollection(query, findOptions, useCache, false, this.searchHref, followLink('parentCommunity')); } return searchListService$.pipe( getFirstCompletedRemoteData(), diff --git a/src/app/shared/dso-selector/dso-selector/authorized-community-selector/authorized-community-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/authorized-community-selector/authorized-community-selector.component.spec.ts new file mode 100644 index 00000000000..d11291c7568 --- /dev/null +++ b/src/app/shared/dso-selector/dso-selector/authorized-community-selector/authorized-community-selector.component.spec.ts @@ -0,0 +1,71 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CommunityDataService } from '../../../../core/data/community-data.service'; +import { Community } from '../../../../core/shared/community.model'; +import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { ThemedLoadingComponent } from '../../../loading/themed-loading.component'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { ListableObjectComponentLoaderComponent } from '../../../object-collection/shared/listable-object/listable-object-component-loader.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../remote-data.utils'; +import { createPaginatedList } from '../../../testing/utils.test'; +import { VarDirective } from '../../../utils/var.directive'; +import { AuthorizedCommunitySelectorComponent } from './authorized-community-selector.component'; + +describe('AuthorizedCommunitySelectorComponent', () => { + let component: AuthorizedCommunitySelectorComponent; + let fixture: ComponentFixture; + + let communityService; + let community; + + let notificationsService: NotificationsService; + + beforeEach(waitForAsync(() => { + community = Object.assign(new Community(), { + id: 'authorized-community', + }); + communityService = jasmine.createSpyObj('communityService', { + getAuthorizedCommunity: createSuccessfulRemoteDataObject$(createPaginatedList([community])), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['error']); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), AuthorizedCommunitySelectorComponent, VarDirective], + providers: [ + { provide: SearchService, useValue: {} }, + { provide: CommunityDataService, useValue: communityService }, + { provide: NotificationsService, useValue: notificationsService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(AuthorizedCommunitySelectorComponent, { + remove: { imports: [ListableObjectComponentLoaderComponent, ThemedLoadingComponent] }, + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AuthorizedCommunitySelectorComponent); + component = fixture.componentInstance; + component.types = [DSpaceObjectType.COMMUNITY]; + fixture.detectChanges(); + }); + + describe('search', () => { + it('should call getAuthorizedCommunity and return the authorized community in a SearchResult', (done) => { + component.search('', 1).subscribe((resultRD) => { + expect(communityService.getAuthorizedCommunity).toHaveBeenCalled(); + expect(resultRD.payload.page.length).toEqual(1); + expect(resultRD.payload.page[0].indexableObject).toEqual(community); + done(); + }); + }); + }); +}); diff --git a/src/app/shared/dso-selector/dso-selector/authorized-community-selector/authorized-community-selector.component.ts b/src/app/shared/dso-selector/dso-selector/authorized-community-selector/authorized-community-selector.component.ts new file mode 100644 index 00000000000..f5a6ed752ee --- /dev/null +++ b/src/app/shared/dso-selector/dso-selector/authorized-community-selector/authorized-community-selector.component.ts @@ -0,0 +1,109 @@ +import { + AsyncPipe, + CommonModule, + NgClass, + NgIf, +} from '@angular/common'; +import { Component } from '@angular/core'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { CommunityDataService } from '../../../../core/data/community-data.service'; +import { FindListOptions } from '../../../../core/data/find-list-options.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Community } from '../../../../core/shared/community.model'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { hasValue } from '../../../empty.util'; +import { HoverClassDirective } from '../../../hover-class.directive'; +import { ThemedLoadingComponent } from '../../../loading/themed-loading.component'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { CommunitySearchResult } from '../../../object-collection/shared/community-search-result.model'; +import { ListableObjectComponentLoaderComponent } from '../../../object-collection/shared/listable-object/listable-object-component-loader.component'; +import { SearchResult } from '../../../search/models/search-result.model'; +import { followLink } from '../../../utils/follow-link-config.model'; +import { DSOSelectorComponent } from '../dso-selector.component'; + +@Component({ + selector: 'ds-authorized-community-selector', + styleUrls: ['../dso-selector.component.scss'], + templateUrl: '../dso-selector.component.html', + standalone: true, + imports: [ + AsyncPipe, + CommonModule, + FormsModule, + HoverClassDirective, + InfiniteScrollModule, + ListableObjectComponentLoaderComponent, + NgClass, + NgIf, + ReactiveFormsModule, + ThemedLoadingComponent, + TranslateModule, + ], +}) +/** + * Component rendering a list of communities to select from + */ +export class AuthorizedCommunitySelectorComponent extends DSOSelectorComponent { + /** + * If present this value is used to filter community list by entity type + */ + + constructor( + protected searchService: SearchService, + protected communityDataService: CommunityDataService, + protected notifcationsService: NotificationsService, + protected translate: TranslateService, + protected dsoNameService: DSONameService, + ) { + super(searchService, notifcationsService, translate, dsoNameService); + } + + /** + * Get a query to send for retrieving the current DSO + */ + getCurrentDSOQuery(): string { + return this.currentDSOId; + } + + /** + * Perform a search for authorized communities with the current query and page + * @param query Query to search objects for + * @param page Page to retrieve + * @param useCache Whether or not to use the cache + */ + search(query: string, page: number, useCache: boolean = true): Observable>>> { + let searchListService$: Observable>> = null; + const findOptions: FindListOptions = { + currentPage: page, + elementsPerPage: this.defaultPagination.pageSize, + }; + + searchListService$ = this.communityDataService + .getAuthorizedCommunity(query, findOptions, useCache, false, followLink('parentCommunity')); + + return searchListService$.pipe( + getFirstCompletedRemoteData(), + map((rd) => Object.assign(new RemoteData(null, null, null, null), rd, { + payload: hasValue(rd.payload) ? buildPaginatedList(rd.payload.pageInfo, rd.payload.page.map((col) => Object.assign(new CommunitySearchResult(), { indexableObject: col }))) : null, + })), + ); + } +} diff --git a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html new file mode 100644 index 00000000000..4f6cdee1e32 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html @@ -0,0 +1,12 @@ +
+ + +
diff --git a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts index 7c6994f0837..1077dfdfcb1 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.spec.ts @@ -18,7 +18,7 @@ import { Community } from '../../../../core/shared/community.model'; import { MetadataValue } from '../../../../core/shared/metadata.models'; import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils'; import { RouterStub } from '../../../testing/router.stub'; -import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component'; +import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component'; import { CreateCollectionParentSelectorComponent } from './create-collection-parent-selector.component'; describe('CreateCollectionParentSelectorComponent', () => { @@ -64,7 +64,7 @@ describe('CreateCollectionParentSelectorComponent', () => { schemas: [NO_ERRORS_SCHEMA], }) .overrideComponent(CreateCollectionParentSelectorComponent, { - remove: { imports: [DSOSelectorComponent] }, + remove: { imports: [AuthorizedCommunitySelectorComponent] }, }) .compileComponents(); diff --git a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts index 05efb987a33..5159a408bdd 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts @@ -22,7 +22,7 @@ import { } from '../../../../core/cache/models/sort-options.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; -import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component'; +import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component'; import { DSOSelectorModalWrapperComponent, SelectorActionType, @@ -34,9 +34,9 @@ import { @Component({ selector: 'ds-base-create-collection-parent-selector', - templateUrl: '../dso-selector-modal-wrapper.component.html', + templateUrl: './create-collection-parent-selector.component.html', standalone: true, - imports: [NgIf, DSOSelectorComponent, TranslateModule], + imports: [NgIf, AuthorizedCommunitySelectorComponent, TranslateModule], }) export class CreateCollectionParentSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit { objectType = DSpaceObjectType.COLLECTION; diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html index a8ec02239d3..1c195e257cd 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html @@ -15,6 +15,8 @@ {{'dso-selector.create.community.sub-level' | translate}} - + diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts index 04922d4deb4..b457419d913 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.spec.ts @@ -21,7 +21,7 @@ import { Community } from '../../../../core/shared/community.model'; import { MetadataValue } from '../../../../core/shared/metadata.models'; import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils'; import { RouterStub } from '../../../testing/router.stub'; -import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component'; +import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component'; import { CreateCommunityParentSelectorComponent } from './create-community-parent-selector.component'; describe('CreateCommunityParentSelectorComponent', () => { @@ -69,7 +69,7 @@ describe('CreateCommunityParentSelectorComponent', () => { schemas: [NO_ERRORS_SCHEMA], }) .overrideComponent(CreateCommunityParentSelectorComponent, { - remove: { imports: [DSOSelectorComponent] }, + remove: { imports: [AuthorizedCommunitySelectorComponent] }, }) .compileComponents(); diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts index dc49fcaa8af..0e950dc562c 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts @@ -29,7 +29,7 @@ import { FeatureID } from '../../../../core/data/feature-authorization/feature-i import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; import { hasValue } from '../../../empty.util'; -import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component'; +import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component'; import { DSOSelectorModalWrapperComponent, SelectorActionType, @@ -49,7 +49,7 @@ import { standalone: true, imports: [ AsyncPipe, - DSOSelectorComponent, + AuthorizedCommunitySelectorComponent, NgIf, TranslateModule, ], diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html new file mode 100644 index 00000000000..92041b39705 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html @@ -0,0 +1,13 @@ +
+ + +
diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts index 43b12889254..08280fb85c7 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.spec.ts @@ -18,7 +18,7 @@ import { Collection } from '../../../../core/shared/collection.model'; import { MetadataValue } from '../../../../core/shared/metadata.models'; import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils'; import { RouterStub } from '../../../testing/router.stub'; -import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component'; +import { AuthorizedCollectionSelectorComponent } from '../../dso-selector/authorized-collection-selector/authorized-collection-selector.component'; import { EditCollectionSelectorComponent } from './edit-collection-selector.component'; describe('EditCollectionSelectorComponent', () => { @@ -64,7 +64,7 @@ describe('EditCollectionSelectorComponent', () => { }) .overrideComponent(EditCollectionSelectorComponent, { remove: { - imports: [DSOSelectorComponent], + imports: [AuthorizedCollectionSelectorComponent], }, }) .compileComponents(); diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts index 611a4f13dec..3972a8053b5 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts @@ -18,7 +18,7 @@ import { } from '../../../../core/cache/models/sort-options.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; -import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component'; +import { AuthorizedCollectionSelectorComponent } from '../../dso-selector/authorized-collection-selector/authorized-collection-selector.component'; import { DSOSelectorModalWrapperComponent, SelectorActionType, @@ -31,9 +31,9 @@ import { @Component({ selector: 'ds-base-edit-collection-selector', - templateUrl: '../dso-selector-modal-wrapper.component.html', + templateUrl: './edit-collection-selector.component.html', standalone: true, - imports: [NgIf, DSOSelectorComponent, TranslateModule], + imports: [NgIf, AuthorizedCollectionSelectorComponent, TranslateModule], }) export class EditCollectionSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit { objectType = DSpaceObjectType.COLLECTION; diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html new file mode 100644 index 00000000000..1f8e6ec79f6 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html @@ -0,0 +1,12 @@ +
+ + +
diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts index cd5c0d1831a..750df1c268f 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.spec.ts @@ -18,7 +18,7 @@ import { Community } from '../../../../core/shared/community.model'; import { MetadataValue } from '../../../../core/shared/metadata.models'; import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils'; import { RouterStub } from '../../../testing/router.stub'; -import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component'; +import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component'; import { EditCommunitySelectorComponent } from './edit-community-selector.component'; describe('EditCommunitySelectorComponent', () => { @@ -64,7 +64,7 @@ describe('EditCommunitySelectorComponent', () => { }) .overrideComponent(EditCommunitySelectorComponent, { remove: { - imports: [DSOSelectorComponent], + imports: [AuthorizedCommunitySelectorComponent], }, }) .compileComponents(); diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts index 3f7ede0de0d..dda75a7d936 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts @@ -18,7 +18,7 @@ import { } from '../../../../core/cache/models/sort-options.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; -import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component'; +import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component'; import { DSOSelectorModalWrapperComponent, SelectorActionType, @@ -31,9 +31,9 @@ import { @Component({ selector: 'ds-base-edit-community-selector', - templateUrl: '../dso-selector-modal-wrapper.component.html', + templateUrl: './edit-community-selector.component.html', standalone: true, - imports: [NgIf, DSOSelectorComponent, TranslateModule], + imports: [NgIf, AuthorizedCommunitySelectorComponent, TranslateModule], }) export class EditCommunitySelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index 7224f1843dd..e54705bb19a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -1,4 +1,4 @@ -
@@ -19,8 +19,7 @@ -
+
@@ -78,7 +77,7 @@ -
+
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss index 4e58759f4e7..ca8924da1ab 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss @@ -14,3 +14,13 @@ -moz-appearance: none; appearance: none; } + +.invalid-feedback { + margin-top: 0; +} + +.col-form-label { + padding-top: 0; + padding-bottom: 0; + margin-bottom: 0.5rem; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index ab50bd19dfc..48a8ebbc0cc 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -440,6 +440,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo * Unsubscribe from all subscriptions */ ngOnDestroy(): void { + super.ngOnDestroy(); this.subs .filter((sub) => hasValue(sub)) .forEach((sub) => sub.unsubscribe()); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts index a54e379cacb..883da2295ab 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.spec.ts @@ -9,12 +9,9 @@ import { } from '@angular/forms'; import { DISABLED_MATCHER_PROVIDER, - DynamicFormControlRelation, DynamicFormRelationService, HIDDEN_MATCHER, HIDDEN_MATCHER_PROVIDER, - MATCH_VISIBLE, - OR_OPERATOR, REQUIRED_MATCHER_PROVIDER, } from '@ng-dynamic-forms/core'; @@ -26,6 +23,7 @@ import { import { FormBuilderService } from '../form-builder.service'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; import { DsDynamicTypeBindRelationService } from './ds-dynamic-type-bind-relation.service'; +import { getTypeBindRelations } from './type-bind.utils'; describe('DSDynamicTypeBindRelationService test suite', () => { let service: DsDynamicTypeBindRelationService; @@ -85,7 +83,7 @@ describe('DSDynamicTypeBindRelationService test suite', () => { }); it('Should get 1 related form models for mock relation model data', () => { const testModel = mockInputWithTypeBindModel; - testModel.typeBindRelations = getTypeBindRelations(['boundType']); + testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'dc.type'); const relatedModels = service.getRelatedFormModel(testModel); expect(relatedModels).toHaveSize(1); }); @@ -94,7 +92,7 @@ describe('DSDynamicTypeBindRelationService test suite', () => { describe('Test matchesCondition method', () => { it('Should receive one subscription to dc.type type binding"', () => { const testModel = mockInputWithTypeBindModel; - testModel.typeBindRelations = getTypeBindRelations(['boundType']); + testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'dc.type'); const dcTypeControl = new UntypedFormControl(); dcTypeControl.setValue('boundType'); let subscriptions = service.subscribeRelations(testModel, dcTypeControl); @@ -103,7 +101,7 @@ describe('DSDynamicTypeBindRelationService test suite', () => { it('Expect hasMatch to be true (ie. this should be hidden)', () => { const testModel = mockInputWithTypeBindModel; - testModel.typeBindRelations = getTypeBindRelations(['boundType']); + testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'dc.type'); const dcTypeControl = new UntypedFormControl(); dcTypeControl.setValue('boundType'); testModel.typeBindRelations[0].when[0].value = 'anotherType'; @@ -118,7 +116,7 @@ describe('DSDynamicTypeBindRelationService test suite', () => { it('Expect hasMatch to be false (ie. this should NOT be hidden)', () => { const testModel = mockInputWithTypeBindModel; - testModel.typeBindRelations = getTypeBindRelations(['boundType']); + testModel.typeBindRelations = getTypeBindRelations(['boundType'], 'dc.type'); const dcTypeControl = new UntypedFormControl(); dcTypeControl.setValue('boundType'); testModel.typeBindRelations[0].when[0].value = 'boundType'; @@ -134,18 +132,3 @@ describe('DSDynamicTypeBindRelationService test suite', () => { }); }); - -function getTypeBindRelations(configuredTypeBindValues: string[]): DynamicFormControlRelation[] { - const bindValues = []; - configuredTypeBindValues.forEach((value) => { - bindValues.push({ - id: 'dc.type', - value: value, - }); - }); - return [{ - match: MATCH_VISIBLE, - operator: OR_OPERATOR, - when: bindValues, - }]; -} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts index 03ca4b26cfc..4f8cff747e6 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service.ts @@ -13,7 +13,6 @@ import { DynamicFormControlModel, DynamicFormControlRelation, DynamicFormRelationService, - MATCH_VISIBLE, OR_OPERATOR, } from '@ng-dynamic-forms/core'; import { Subscription } from 'rxjs'; @@ -216,23 +215,4 @@ export class DsDynamicTypeBindRelationService { return subscriptions; } - /** - * Helper function to construct a typeBindRelations array - * @param configuredTypeBindValues - */ - public getTypeBindRelations(configuredTypeBindValues: string[]): DynamicFormControlRelation[] { - const bindValues = []; - configuredTypeBindValues.forEach((value) => { - bindValues.push({ - id: 'dc.type', - value: value, - }); - }); - return [{ - match: MATCH_VISIBLE, - operator: OR_OPERATOR, - when: bindValues, - }]; - } - } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html index 944e4650abd..a71f6fa5a26 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html @@ -1,5 +1,5 @@
- + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss index 97698b2102e..c76d9fa95c3 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss @@ -4,4 +4,5 @@ legend { font-size: initial; + margin-bottom: 0; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts index 78e50de898d..68a5d5bc84b 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts @@ -45,10 +45,10 @@ import { FormFieldMetadataValueObject } from '../../../models/form-field-metadat import { DsDynamicTagComponent } from './dynamic-tag.component'; import { DynamicTagModel } from './dynamic-tag.model'; -function createKeyUpEvent(key: number) { +function createKeyUpEvent(key: string) { /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ const event = { - keyCode: key, preventDefault: () => { + key: key, preventDefault: () => { }, stopPropagation: () => { }, }; @@ -278,8 +278,8 @@ describe('DsDynamicTagComponent test suite', () => { expect(tagComp.chips.getChipsItems()).toEqual(chips.getChipsItems()); }); - it('should add an item on ENTER or key press is \',\' or \';\'', fakeAsync(() => { - let event = createKeyUpEvent(13); + it('should add an item on ENTER or key press is \',\'', fakeAsync(() => { + let event = createKeyUpEvent('Enter'); tagComp.currentValue = 'test value'; tagFixture.detectChanges(); @@ -290,7 +290,7 @@ describe('DsDynamicTagComponent test suite', () => { expect(tagComp.model.value).toEqual(['test value']); expect(tagComp.currentValue).toBeNull(); - event = createKeyUpEvent(188); + event = createKeyUpEvent(','); tagComp.currentValue = 'test value'; tagFixture.detectChanges(); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts index d8f12197d2a..431ca32c375 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts @@ -220,13 +220,15 @@ export class DsDynamicTagComponent extends DsDynamicVocabularyComponent implemen } /** - * Add a new tag with typed text when typing 'Enter' or ',' or ';' + * Add a new tag with typed text when typing 'Enter' or ',' + * Tests the key rather than keyCode as keyCodes can vary + * based on keyboard layout (and do not consider Shift mod) * @param event the keyUp event */ onKeyUp(event) { - if (event.keyCode === 13 || event.keyCode === 188) { + if (event.key === 'Enter' || event.key === ',') { event.preventDefault(); - // Key: 'Enter' or ',' or ';' + // Key: 'Enter' or ',' this.addTagsToChips(); event.stopPropagation(); } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/type-bind.utils.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/type-bind.utils.ts new file mode 100644 index 00000000000..1d09e9fafbe --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/type-bind.utils.ts @@ -0,0 +1,48 @@ +import { + DynamicFormControlRelation, + MATCH_ENABLED, + MATCH_VISIBLE, + OR_OPERATOR, +} from '@ng-dynamic-forms/core'; + +/** + * Get the type bind values from the REST data for a specific field + * The return value is any[] in the method signature but in reality it's + * returning the 'relation' that'll be used for a dynamic matcher when filtering + * fields in type bind, made up of a 'match' outcome (make this field visible), an 'operator' + * (OR) and a 'when' condition (the bindValues array). + * @param configuredTypeBindValues array of types from the submission definition (CONFIG_DATA) + * @param typeField + * @private + * @return DynamicFormControlRelation[] array with one relation in it, for type bind matching to show a field + */ +export function getTypeBindRelations(configuredTypeBindValues: string[], typeField: string): DynamicFormControlRelation[] { + const bindValues = []; + configuredTypeBindValues.forEach((value) => { + bindValues.push({ + id: typeField, + value: value, + }); + }); + // match: MATCH_VISIBLE means that if true, the field / component will be visible + // operator: OR means that all the values in the 'when' condition will be compared with OR, not AND + // when: the list of values to match against, in this case the list of strings from ... + // Example: Field [x] will be VISIBLE if item type = book OR item type = book_part + // + // The opposing match value will be the dc.type for the workspace item + // + // MATCH_ENABLED is now also returned, so that hidden type-bound fields that are 'required' + // do not trigger false validation errors + return [ + { + match: MATCH_ENABLED, + operator: OR_OPERATOR, + when: bindValues, + }, + { + match: MATCH_VISIBLE, + operator: OR_OPERATOR, + when: bindValues, + }, + ]; +} diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index 590d5f564e9..1f510fb6e77 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -2,12 +2,7 @@ import { Inject, InjectionToken, } from '@angular/core'; -import { - DynamicFormControlLayout, - DynamicFormControlRelation, - MATCH_VISIBLE, - OR_OPERATOR, -} from '@ng-dynamic-forms/core'; +import { DynamicFormControlLayout } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; import uniqueId from 'lodash/uniqueId'; @@ -28,6 +23,7 @@ import { DynamicRowArrayModel, DynamicRowArrayModelConfig, } from '../ds-dynamic-form-ui/models/ds-dynamic-row-array-model'; +import { getTypeBindRelations } from '../ds-dynamic-form-ui/type-bind.utils'; import { FormFieldModel } from '../models/form-field.model'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; import { RelationshipOptions } from '../models/relationship-options.model'; @@ -98,7 +94,7 @@ export abstract class FieldParser { metadataFields: this.getAllFieldIds(), hasSelectableMetadata: isNotEmpty(this.configData.selectableMetadata), isDraggable, - typeBindRelations: isNotEmpty(this.configData.typeBind) ? this.getTypeBindRelations(this.configData.typeBind, + typeBindRelations: isNotEmpty(this.configData.typeBind) ? getTypeBindRelations(this.configData.typeBind, this.parserOptions.typeField) : null, groupFactory: () => { let model; @@ -327,7 +323,7 @@ export abstract class FieldParser { // If typeBind is configured if (isNotEmpty(this.configData.typeBind)) { - (controlModel as DsDynamicInputModel).typeBindRelations = this.getTypeBindRelations(this.configData.typeBind, + (controlModel as DsDynamicInputModel).typeBindRelations = getTypeBindRelations(this.configData.typeBind, this.parserOptions.typeField); } @@ -356,38 +352,6 @@ export abstract class FieldParser { ); } - /** - * Get the type bind values from the REST data for a specific field - * The return value is any[] in the method signature but in reality it's - * returning the 'relation' that'll be used for a dynamic matcher when filtering - * fields in type bind, made up of a 'match' outcome (make this field visible), an 'operator' - * (OR) and a 'when' condition (the bindValues array). - * @param configuredTypeBindValues array of types from the submission definition (CONFIG_DATA) - * @param typeField - * @private - * @return DynamicFormControlRelation[] array with one relation in it, for type bind matching to show a field - */ - private getTypeBindRelations(configuredTypeBindValues: string[], typeField: string): DynamicFormControlRelation[] { - const bindValues = []; - configuredTypeBindValues.forEach((value) => { - bindValues.push({ - id: typeField, - value: value, - }); - }); - // match: MATCH_VISIBLE means that if true, the field / component will be visible - // operator: OR means that all the values in the 'when' condition will be compared with OR, not AND - // when: the list of values to match against, in this case the list of strings from ... - // Example: Field [x] will be VISIBLE if item type = book OR item type = book_part - // - // The opposing match value will be the dc.type for the workspace item - return [{ - match: MATCH_VISIBLE, - operator: OR_OPERATOR, - when: bindValues, - }]; - } - protected hasRegex() { return hasValue(this.configData.input.regex); } diff --git a/src/app/shared/mocks/item.mock.ts b/src/app/shared/mocks/item.mock.ts index 7f723bfd61a..c8a8c19ede4 100644 --- a/src/app/shared/mocks/item.mock.ts +++ b/src/app/shared/mocks/item.mock.ts @@ -293,4 +293,55 @@ export const ItemMock: Item = Object.assign(new Item(), { }, ), }); + +export const NonDiscoverableItemMock: Item = Object.assign(new Item(), { + handle: '10673/7', + lastModified: '2017-04-24T19:44:08.178+0000', + isArchived: true, + isDiscoverable: false, + isWithdrawn: false, + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([ + MockOriginalBundle, + ])), + _links:{ + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f358', + }, + }, + id: '0ec7ff22-f211-40ab-a69e-c819b0b1f358', + uuid: '0ec7ff22-f211-40ab-a69e-c819b0b1f358', + type: 'item', + metadata: { + 'dc.date.accessioned': [ + { + language: null, + value: '1650-06-26T19:58:25Z', + }, + ], + 'dc.date.available': [ + { + language: null, + value: '1650-06-26T19:58:25Z', + }, + ], + 'dc.date.issued': [ + { + language: null, + value: '1650-06-26', + }, + ], + 'dc.identifier.uri': [ + { + language: null, + value: 'http://dspace7.4science.it/xmlui/handle/10673/7', + }, + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Test Non-Discoverable', + }, + ], + }, +}); /* eslint-enable @typescript-eslint/no-shadow */ diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts index 5bcda378375..7366bca9dba 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component.ts @@ -49,6 +49,11 @@ export class ItemDetailPreviewFieldComponent { */ @Input() metadata: string | string[]; + /** + * Escape HTML in the metadata value + */ + @Input() escapeMetadataHTML: boolean; + /** * The placeholder if there are no value to show */ @@ -66,6 +71,6 @@ export class ItemDetailPreviewFieldComponent { * @returns {string[]} the matching string values or an empty array. */ allMetadataValues(keyOrKeys: string | string[]): string[] { - return Metadata.allValues([this.object.hitHighlights, this.item.metadata], keyOrKeys); + return Metadata.allValues(this.item.metadata, keyOrKeys, this.object.hitHighlights, undefined, this.escapeMetadataHTML); } } diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/themed-item-detail-preview-field.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/themed-item-detail-preview-field.component.ts index ebb283728a4..93c1665cfe3 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/themed-item-detail-preview-field.component.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/themed-item-detail-preview-field.component.ts @@ -24,6 +24,7 @@ export class ThemedItemDetailPreviewFieldComponent extends ThemedComponent , K ext * Gets all matching metadata string values from hitHighlights or dso metadata, preferring hitHighlights. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {string[]} the matching string values or an empty array. */ - allMetadataValues(keyOrKeys: string | string[]): string[] { - return Metadata.allValues([this.object.hitHighlights, this.dso.metadata], keyOrKeys); + allMetadataValues(keyOrKeys: string | string[], escapeHTML = true): string[] { + return Metadata.allValues(this.dso.metadata, keyOrKeys, this.object.hitHighlights, undefined, escapeHTML); } /** * Gets the first matching metadata string value from hitHighlights or dso metadata, preferring hitHighlights. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {string} the first matching string value, or `undefined`. */ - firstMetadataValue(keyOrKeys: string | string[]): string { - return Metadata.firstValue([this.object.hitHighlights, this.dso.metadata], keyOrKeys); + firstMetadataValue(keyOrKeys: string | string[], escapeHTML = true): string { + return Metadata.firstValue(this.dso.metadata, keyOrKeys, this.object.hitHighlights, undefined, escapeHTML); } } diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html index 464f8fec909..57e37ae8ceb 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html @@ -29,9 +29,9 @@

- +

- +

diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts index d5c8ea42e89..7b5461e7ca6 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.spec.ts @@ -28,6 +28,7 @@ import { RemoteData } from '../../../../../core/data/remote-data'; import { Bitstream } from '../../../../../core/shared/bitstream.model'; import { HALEndpointService } from '../../../../../core/shared/hal-endpoint.service'; import { Item } from '../../../../../core/shared/item.model'; +import { MetadataValue } from '../../../../../core/shared/metadata.models'; import { PageInfo } from '../../../../../core/shared/page-info.model'; import { UUIDService } from '../../../../../core/shared/uuid.service'; import { ThemedThumbnailComponent } from '../../../../../thumbnail/themed-thumbnail.component'; @@ -43,20 +44,21 @@ import { TruncatePipe } from '../../../../utils/truncate.pipe'; import { ItemSearchResultGridElementComponent } from './item-search-result-grid-element.component'; const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); -mockItemWithMetadata.hitHighlights = {}; const dcTitle = 'This is just another title'; -mockItemWithMetadata.indexableObject = Object.assign(new Item(), { - hitHighlights: { - 'dc.title': [{ +mockItemWithMetadata.hitHighlights = { + 'dc.title': [ + Object.assign(new MetadataValue(), { value: dcTitle, - }], - }, + }), + ], +}; +mockItemWithMetadata.indexableObject = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), metadata: { 'dc.title': [ { language: 'en_US', - value: dcTitle, + value: 'This is just another title', }, ], 'dc.contributor.author': [ diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts index a0c8ec84cef..cd9a09b40a1 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.ts @@ -57,6 +57,6 @@ export class ItemSearchResultGridElementComponent extends SearchResultGridElemen ngOnInit(): void { super.ngOnInit(); this.itemPageRoute = getItemPageRoute(this.dso); - this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.dso); + this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.dso, true); } } diff --git a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts index e8b999fb9fc..7824e759f82 100644 --- a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts @@ -51,20 +51,22 @@ export class SearchResultGridElementComponent, K exten * Gets all matching metadata string values from hitHighlights or dso metadata, preferring hitHighlights. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {string[]} the matching string values or an empty array. */ - allMetadataValues(keyOrKeys: string | string[]): string[] { - return Metadata.allValues([this.object.hitHighlights, this.dso.metadata], keyOrKeys); + allMetadataValues(keyOrKeys: string | string[], escapeHTML = true): string[] { + return Metadata.allValues(this.dso.metadata, keyOrKeys, this.object.hitHighlights, undefined, escapeHTML); } /** * Gets the first matching metadata string value from hitHighlights or dso metadata, preferring hitHighlights. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute * @returns {string} the first matching string value, or `undefined`. */ - firstMetadataValue(keyOrKeys: string | string[]): string { - return Metadata.firstValue([this.object.hitHighlights, this.dso.metadata], keyOrKeys); + firstMetadataValue(keyOrKeys: string | string[], escapeHTML = true): string { + return Metadata.firstValue(this.dso.metadata, keyOrKeys, this.object.hitHighlights, undefined, escapeHTML); } private isCollapsed(): Observable { diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html index 035a7ff9f59..b0c16f1f1d9 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html @@ -14,16 +14,16 @@

( + [innerHTML]="item.firstMetadataValue('dc.publisher', undefined, true) + ', '"> ) + [innerHTML]="item.firstMetadataValue('dc.date.issued', undefined, true) || ('mydspace.results.no-date' | translate)">) {{'mydspace.results.no-authors' | translate}} + *ngFor="let author of item.allMetadataValues(['dc.contributor.author', 'dc.creator', 'dc.contributor.*'], undefined, true); let last=last;"> ; @@ -33,8 +33,8 @@

- +

diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts index 583b857063b..8398acdbcb9 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.ts @@ -94,7 +94,7 @@ export class ItemListPreviewComponent implements OnInit { ngOnInit(): void { this.showThumbnails = this.appConfig.browseBy.showThumbnails; - this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.item); + this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.item, true); } diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html index 20e11953030..b8318b1b15f 100644 --- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html +++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-types/item/item-search-result-list-element.component.html @@ -23,22 +23,22 @@ [innerHTML]="dsoTitle"> - - ( - , - ) + + ( + , + ) - - + + ; -
+
+ [innerHTML]="abstract">
diff --git a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts index c4251c3597f..d45eea80827 100644 --- a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts @@ -41,7 +41,7 @@ export class SearchResultListElementComponent, K exten ngOnInit(): void { if (hasValue(this.object)) { this.dso = this.object.indexableObject; - this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.dso); + this.dsoTitle = this.dsoNameService.getHitHighlights(this.object, this.dso, true); } } @@ -49,11 +49,13 @@ export class SearchResultListElementComponent, K exten * Gets all matching metadata string values from hitHighlights or dso metadata. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute. Defaults to `true` because we + * always use `[innerHTML]` in the templates to render metadata due to the hit highlights. * @returns {string[]} the matching string values or an empty array. */ - allMetadataValues(keyOrKeys: string | string[]): string[] { - const dsoMetadata: string[] = Metadata.allValues([this.dso.metadata], keyOrKeys); - const highlights: string[] = Metadata.allValues([this.object.hitHighlights], keyOrKeys); + allMetadataValues(keyOrKeys: string | string[], escapeHTML = true): string[] { + const dsoMetadata: string[] = Metadata.allValues(this.dso.metadata, keyOrKeys, undefined, undefined, escapeHTML); + const highlights: string[] = Metadata.allValues({}, keyOrKeys, this.object.hitHighlights, undefined, escapeHTML); const removedHighlights: string[] = highlights.map(str => str.replace(/<\/?em>/g, '')); for (let i = 0; i < removedHighlights.length; i++) { const index = dsoMetadata.indexOf(removedHighlights[i]); @@ -68,10 +70,12 @@ export class SearchResultListElementComponent, K exten * Gets the first matching metadata string value from hitHighlights or dso metadata, preferring hitHighlights. * * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see [[Metadata]]. + * @param escapeHTML Whether the HTML is used inside a `[innerHTML]` attribute. Defaults to `true` because we + * always use `[innerHTML]` in the templates to render metadata due to the hit highlights. * @returns {string} the first matching string value, or `undefined`. */ - firstMetadataValue(keyOrKeys: string | string[]): string { - return Metadata.firstValue([this.object.hitHighlights, this.dso.metadata], keyOrKeys); + firstMetadataValue(keyOrKeys: string | string[], escapeHTML = true): string { + return Metadata.firstValue(this.dso.metadata, keyOrKeys, this.object.hitHighlights, undefined, escapeHTML); } /** diff --git a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.ts b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.ts index e65883cd3ec..882cc6df83c 100644 --- a/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.ts +++ b/src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.ts @@ -87,7 +87,7 @@ export class SidebarSearchListElementComponent, K exte getParentTitle(): Observable { return this.getParent().pipe( map((parentRD: RemoteData) => { - return hasValue(parentRD) && hasValue(parentRD.payload) ? this.dsoNameService.getName(parentRD.payload) : undefined; + return hasValue(parentRD) && hasValue(parentRD.payload) ? this.dsoNameService.getName(parentRD.payload, true) : undefined; }), ); } diff --git a/src/app/shared/object-select/collection-select/collection-select.component.html b/src/app/shared/object-select/collection-select/collection-select.component.html index 03491e74912..a2f2176986a 100644 --- a/src/app/shared/object-select/collection-select/collection-select.component.html +++ b/src/app/shared/object-select/collection-select/collection-select.component.html @@ -36,7 +36,7 @@ [ngClass]="{'btn-danger': dangerConfirm, 'btn-primary': !dangerConfirm}" [dsBtnDisabled]="selectedIds?.length === 0" (click)="confirmSelected()"> - {{confirmButton | translate}} + {{confirmButton | translate}}
diff --git a/src/app/shared/resource-policies/form/resource-policy-form.model.ts b/src/app/shared/resource-policies/form/resource-policy-form.model.ts index 98ecd678278..2920830b2c2 100644 --- a/src/app/shared/resource-policies/form/resource-policy-form.model.ts +++ b/src/app/shared/resource-policies/form/resource-policy-form.model.ts @@ -39,6 +39,10 @@ const policyActionList: DynamicFormOptionConfig[] = [ label: ActionType.WRITE.toString(), value: ActionType.WRITE, }, + { + label: ActionType.ADD.toString(), + value: ActionType.ADD, + }, { label: ActionType.REMOVE.toString(), value: ActionType.REMOVE, diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html index c97af4bc250..1c60c59bcd5 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html @@ -2,7 +2,8 @@ [tabIndex]="-1" [routerLink]="[searchLink]" [queryParams]="addQueryParams$ | async" - (click)="announceFilter(); filterService.minimizeAll()"> + (click)="announceFilter(); filterService.minimizeAll()" + rel="nofollow">