From 88666cc67290450dacde429e3d8fb22322c7a06f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laure-H=C3=A9l=C3=A8ne=20Bruneton?= Date: Fri, 12 Jul 2024 15:27:21 +0200 Subject: [PATCH] feat(editor): new route to duplicate local record --- apps/metadata-editor/src/app/app.routes.ts | 6 ++ .../src/app/duplicate-record.resolver.spec.ts | 84 +++++++++++++++++++ .../src/app/duplicate-record.resolver.ts | 42 ++++++++++ libs/api/metadata-converter/src/index.ts | 1 + .../src/lib/gn4/gn4-repository.spec.ts | 48 +++++++++++ .../repository/src/lib/gn4/gn4-repository.ts | 23 +++++ .../records-repository.interface.ts | 12 +++ 7 files changed, 216 insertions(+) create mode 100644 apps/metadata-editor/src/app/duplicate-record.resolver.spec.ts create mode 100644 apps/metadata-editor/src/app/duplicate-record.resolver.ts diff --git a/apps/metadata-editor/src/app/app.routes.ts b/apps/metadata-editor/src/app/app.routes.ts index 878df29ce9..d8c5da1256 100644 --- a/apps/metadata-editor/src/app/app.routes.ts +++ b/apps/metadata-editor/src/app/app.routes.ts @@ -10,6 +10,7 @@ import { SearchRecordsComponent } from './records/search-records/search-records- import { MyOrgUsersComponent } from './my-org-users/my-org-users.component' import { MyOrgRecordsComponent } from './records/my-org-records/my-org-records.component' import { NewRecordResolver } from './new-record.resolver' +import { DuplicateRecordResolver } from './duplicate-record.resolver' export const appRoutes: Route[] = [ { path: '', redirectTo: 'catalog/search', pathMatch: 'prefix' }, @@ -101,6 +102,11 @@ export const appRoutes: Route[] = [ component: EditPageComponent, resolve: { record: NewRecordResolver }, }, + { + path: 'duplicate/:uuid', + component: EditPageComponent, + resolve: { record: DuplicateRecordResolver }, + }, { path: 'edit/:uuid', component: EditPageComponent, diff --git a/apps/metadata-editor/src/app/duplicate-record.resolver.spec.ts b/apps/metadata-editor/src/app/duplicate-record.resolver.spec.ts new file mode 100644 index 0000000000..643e6f4f3c --- /dev/null +++ b/apps/metadata-editor/src/app/duplicate-record.resolver.spec.ts @@ -0,0 +1,84 @@ +import { TestBed } from '@angular/core/testing' +import { DuplicateRecordResolver } from './duplicate-record.resolver' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { NotificationsService } from '@geonetwork-ui/feature/notifications' +import { of, throwError } from 'rxjs' +import { DATASET_RECORDS } from '@geonetwork-ui/common/fixtures' +import { ActivatedRouteSnapshot, convertToParamMap } from '@angular/router' +import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' +import { TranslateModule } from '@ngx-translate/core' +import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface' + +class NotificationsServiceMock { + showNotification = jest.fn() +} +class RecordsRepositoryMock { + openRecordForDuplication = jest.fn(() => + of([DATASET_RECORDS[0], 'blabla', false]) + ) +} + +const activatedRoute = { + paramMap: convertToParamMap({ id: DATASET_RECORDS[0].uniqueIdentifier }), +} as ActivatedRouteSnapshot + +describe('DuplicateRecordResolver', () => { + let resolver: DuplicateRecordResolver + let recordsRepository: RecordsRepositoryInterface + let notificationsService: NotificationsService + let resolvedData: [CatalogRecord, string, boolean] + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, TranslateModule.forRoot()], + providers: [ + { provide: NotificationsService, useClass: NotificationsServiceMock }, + { + provide: RecordsRepositoryInterface, + useClass: RecordsRepositoryMock, + }, + ], + }) + resolver = TestBed.inject(DuplicateRecordResolver) + recordsRepository = TestBed.inject(RecordsRepositoryInterface) + notificationsService = TestBed.inject(NotificationsService) + }) + + it('should be created', () => { + expect(resolver).toBeTruthy() + }) + + describe('load record success', () => { + beforeEach(() => { + resolvedData = undefined + resolver.resolve(activatedRoute).subscribe((r) => (resolvedData = r)) + }) + it('should load record by uuid', () => { + expect(resolvedData).toEqual([ + DATASET_RECORDS[0], + 'blabla', + false, + ]) + }) + }) + + describe('load record failure', () => { + beforeEach(() => { + recordsRepository.openRecordForDuplication = () => + throwError(() => new Error('oopsie')) + resolvedData = undefined + resolver.resolve(activatedRoute).subscribe((r) => (resolvedData = r)) + }) + it('should not emit anything', () => { + expect(resolvedData).toBeUndefined() + }) + it('should show error notification', () => { + expect(notificationsService.showNotification).toHaveBeenCalledWith({ + type: 'error', + title: 'editor.record.loadError.title', + text: 'editor.record.loadError.body oopsie', + closeMessage: 'editor.record.loadError.closeMessage', + }) + }) + }) +}) diff --git a/apps/metadata-editor/src/app/duplicate-record.resolver.ts b/apps/metadata-editor/src/app/duplicate-record.resolver.ts new file mode 100644 index 0000000000..89df9a5699 --- /dev/null +++ b/apps/metadata-editor/src/app/duplicate-record.resolver.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core' +import { ActivatedRouteSnapshot } from '@angular/router' +import { catchError, EMPTY, Observable } from 'rxjs' +import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' +import { NotificationsService } from '@geonetwork-ui/feature/notifications' +import { TranslateService } from '@ngx-translate/core' +import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface' + +@Injectable({ + providedIn: 'root', +}) +export class DuplicateRecordResolver { + constructor( + private recordsRepository: RecordsRepositoryInterface, + private notificationsService: NotificationsService, + private translateService: TranslateService + ) {} + + resolve( + route: ActivatedRouteSnapshot + ): Observable<[CatalogRecord, string, boolean]> { + return this.recordsRepository + .openRecordForDuplication(route.paramMap.get('uuid')) + .pipe( + catchError((error) => { + this.notificationsService.showNotification({ + type: 'error', + title: this.translateService.instant( + 'editor.record.loadError.title' + ), + text: `${this.translateService.instant( + 'editor.record.loadError.body' + )} ${error.message}`, + closeMessage: this.translateService.instant( + 'editor.record.loadError.closeMessage' + ), + }) + return EMPTY + }) + ) + } +} diff --git a/libs/api/metadata-converter/src/index.ts b/libs/api/metadata-converter/src/index.ts index 169d8dfcb6..a5e5f3d06e 100644 --- a/libs/api/metadata-converter/src/index.ts +++ b/libs/api/metadata-converter/src/index.ts @@ -1,3 +1,4 @@ +export * from './lib/base.converter' export * from './lib/iso19139' export * from './lib/iso19115-3' export * from './lib/find-converter' diff --git a/libs/api/repository/src/lib/gn4/gn4-repository.spec.ts b/libs/api/repository/src/lib/gn4/gn4-repository.spec.ts index 3853a2e45f..d9e978b790 100644 --- a/libs/api/repository/src/lib/gn4/gn4-repository.spec.ts +++ b/libs/api/repository/src/lib/gn4/gn4-repository.spec.ts @@ -323,6 +323,54 @@ describe('Gn4Repository', () => { }) }) }) + describe('openRecordForDuplication', () => { + let record: CatalogRecord + let recordSource: string + let savedOnce: boolean + + const date = new Date('2024-07-11') + jest.useFakeTimers().setSystemTime(date) + + beforeEach(async () => { + ;(gn4RecordsApi.getRecordAs as jest.Mock).mockReturnValueOnce( + of(DATASET_RECORD_SIMPLE_AS_XML).pipe(map((xml) => ({ body: xml }))) + ) + ;[record, recordSource, savedOnce] = await lastValueFrom( + repository.openRecordForDuplication('1234-5678') + ) + }) + it('calls the API to get the record as XML', () => { + expect(gn4RecordsApi.getRecordAs).toHaveBeenCalledWith( + '1234-5678', + undefined, + expect.anything(), + undefined, + undefined, + undefined, + expect.anything(), + expect.anything(), + undefined, + expect.anything() + ) + }) + it('parses the XML record into a native object, and updates the id and title', () => { + expect(record).toMatchObject({ + uniqueIdentifier: `TEMP-ID-1720656000000`, + title: + 'A very interesting dataset (un jeu de données très intéressant) (Copy)', + }) + }) + it('saves the duplicated record as draft', () => { + const hasDraft = repository.recordHasDraft(`TEMP-ID-1720656000000`) + expect(hasDraft).toBe(true) + }) + it('tells the record it has not been saved yet', () => { + expect(savedOnce).toBe(false) + }) + it('returns the record as serialized', () => { + expect(recordSource).toMatch(/ { let recordSource: string diff --git a/libs/api/repository/src/lib/gn4/gn4-repository.ts b/libs/api/repository/src/lib/gn4/gn4-repository.ts index fa17388957..00e2015e2f 100644 --- a/libs/api/repository/src/lib/gn4/gn4-repository.ts +++ b/libs/api/repository/src/lib/gn4/gn4-repository.ts @@ -24,6 +24,7 @@ import { } from '@geonetwork-ui/common/domain/model/search' import { catchError, map, tap } from 'rxjs/operators' import { + BaseConverter, findConverterForDocument, Gn4Converter, Gn4SearchResults, @@ -230,6 +231,28 @@ export class Gn4Repository implements RecordsRepositoryInterface { ) } + openRecordForDuplication( + uniqueIdentifier: string + ): Observable<[CatalogRecord, string, false] | null> { + return this.loadRecordAsXml(uniqueIdentifier).pipe( + switchMap(async (xml) => { + const converter = findConverterForDocument(xml) + const record = await converter.readRecord(xml) + return [record, converter] as [CatalogRecord, BaseConverter] + }), + switchMap(async ([record, converter]) => { + record.uniqueIdentifier = `TEMP-ID-${Date.now()}` + record.title = `${record.title} (Copy)` + const xml = await converter.writeRecord(record) + window.localStorage.setItem( + this.getLocalStorageKeyForRecord(record.uniqueIdentifier), + xml + ) + return [record, xml, false] as [CatalogRecord, string, false] + }) + ) + } + private serializeRecordToXml( record: CatalogRecord, referenceRecordSource?: string diff --git a/libs/common/domain/src/lib/repository/records-repository.interface.ts b/libs/common/domain/src/lib/repository/records-repository.interface.ts index a8cc83aca2..039eac3812 100644 --- a/libs/common/domain/src/lib/repository/records-repository.interface.ts +++ b/libs/common/domain/src/lib/repository/records-repository.interface.ts @@ -30,6 +30,18 @@ export abstract class RecordsRepositoryInterface { uniqueIdentifier: string ): Observable<[CatalogRecord, string, boolean] | null> + /** + * This emits once: + * - record object with a new unique identifier and suffixed title + * - serialized representation of the record as text + * - false, as the duplicated record is always a draft + * @param uniqueIdentifier + * @returns Observable<[CatalogRecord, string, false] | null> + */ + abstract openRecordForDuplication( + uniqueIdentifier: string + ): Observable<[CatalogRecord, string, false] | null> + /** * @param record * @param referenceRecordSource