diff --git a/apps/metadata-editor-e2e/src/e2e/import.cy.ts b/apps/metadata-editor-e2e/src/e2e/import.cy.ts new file mode 100644 index 0000000000..0c73348dc2 --- /dev/null +++ b/apps/metadata-editor-e2e/src/e2e/import.cy.ts @@ -0,0 +1,79 @@ +// eslint-disable-next-line @nx/enforce-module-boundaries +import { simpleDatasetRecordAsXmlFixture } from '@geonetwork-ui/common/fixtures' + +describe('import', () => { + beforeEach(() => { + cy.login('admin', 'admin', false) + cy.visit('/catalog/search') + }) + + describe('import a record', () => { + beforeEach(() => { + // Open the import overlay + cy.get('[data-test="import-record"]').click() + }) + + it('should show the import menu overlay', () => { + cy.get('gn-ui-import-record').should('be.visible') + cy.get('[data-test="importMenuMainSection"]').should('be.visible') + }) + + describe('import by URL section', () => { + beforeEach(() => { + cy.get('[data-test="importFromUrlButton"]').click() + }) + + it('should show the import by URL section', () => { + cy.get('[data-test="importMenuImportExternalFileSection"]').should( + 'be.visible' + ) + }) + + it('should show the import by URL section', () => { + cy.get('[data-test="importMenuImportExternalFileSection"]').should( + 'be.visible' + ) + }) + + it('should import a record', () => { + cy.get('[data-test="importMenuImportExternalFileSection"]') + .find('gn-ui-url-input') + .type('http://www.marvelous-record/xml/download') + + cy.intercept( + { + method: 'GET', + url: /\/xml\/download$/, + }, + { + statusCode: 200, + body: simpleDatasetRecordAsXmlFixture(), + } + ).as('importUrlRequest') + + cy.get('gn-ui-url-input').find('gn-ui-button').find('button').click() + + // Check that the record is correctly displayed + cy.get('gn-ui-record-form').should('be.visible') + + cy.get('gn-ui-record-form') + .find('gn-ui-form-field') + .eq(0) + .find('input') + .invoke('val') + .should('contain', 'Copy') + }) + + it('should be able to navigate back to the main section', () => { + cy.get( + '[data-test="importMenuImportExternalFileSectionBackButton"]' + ).click() + + cy.get('[data-test="importMenuMainSection"]').should('be.visible') + cy.get('[data-test="importMenuImportExternalFileSection"]').should( + 'not.exist' + ) + }) + }) + }) +}) diff --git a/apps/metadata-editor/src/app/records/records-list.component.ts b/apps/metadata-editor/src/app/records/records-list.component.ts index 5ef8dc82b3..c930da2a02 100644 --- a/apps/metadata-editor/src/app/records/records-list.component.ts +++ b/apps/metadata-editor/src/app/records/records-list.component.ts @@ -59,6 +59,7 @@ export class RecordsListComponent { paginate(page: number) { this.searchService.setPage(page) } + createRecord() { this.router.navigate(['/create']) } diff --git a/apps/metadata-editor/src/app/records/search-records/search-records-list.component.html b/apps/metadata-editor/src/app/records/search-records/search-records-list.component.html index becee4f003..75bbc464c0 100644 --- a/apps/metadata-editor/src/app/records/search-records/search-records-list.component.html +++ b/apps/metadata-editor/src/app/records/search-records/search-records-list.component.html @@ -31,6 +31,30 @@

dashboard.results.listResources
+ + dashboard.importRecord + keyboard_arrow_down + keyboard_arrow_up + + + + Promise.resolve(true)) } describe('SearchRecordsComponent', () => { @@ -81,9 +82,11 @@ describe('SearchRecordsComponent', () => { let fixture: ComponentFixture let router: Router let searchService: SearchService + let searchFacade: SearchFacade beforeEach(() => { TestBed.configureTestingModule({ + schemas: [CUSTOM_ELEMENTS_SCHEMA], providers: [ importProvidersFrom(TranslateModule.forRoot()), { @@ -115,6 +118,8 @@ describe('SearchRecordsComponent', () => { fixture = TestBed.createComponent(SearchRecordsComponent) router = TestBed.inject(Router) searchService = TestBed.inject(SearchService) + searchFacade = TestBed.inject(SearchFacade) + component = fixture.componentInstance fixture.detectChanges() }) @@ -123,6 +128,54 @@ describe('SearchRecordsComponent', () => { expect(component).toBeTruthy() }) + it('should map search filters to searchText$', (done) => { + component.searchText$.subscribe((text) => { + expect(text).toBe('hello world') + done() + }) + }) + + describe('when clicking createRecord', () => { + beforeEach(() => { + component.createRecord() + }) + + it('navigates to the create record page', () => { + expect(router.navigate).toHaveBeenCalledWith(['/create']) + }) + }) + + describe('when importing a record', () => { + beforeEach(() => { + component.duplicateExternalRecord() + }) + + it('sets isImportMenuOpen to true', () => { + expect(component.isImportMenuOpen).toBe(true) + }) + }) + + describe('when closing the import menu', () => { + let overlaySpy: any + + beforeEach(() => { + overlaySpy = { + dispose: jest.fn(), + } + component['overlayRef'] = overlaySpy + + component.closeImportMenu() + }) + + it('sets isImportMenuOpen to false', () => { + expect(component.isImportMenuOpen).toBe(false) + }) + + it('disposes the overlay', () => { + expect(overlaySpy.dispose).toHaveBeenCalled() + }) + }) + describe('when search results', () => { let table, pagination beforeEach(() => { @@ -141,6 +194,7 @@ describe('SearchRecordsComponent', () => { expect(pagination.currentPage).toEqual(currentPage) expect(pagination.totalPages).toEqual(totalPages) }) + describe('when click on a record', () => { const uniqueIdentifier = 123 const singleRecord = { @@ -154,6 +208,7 @@ describe('SearchRecordsComponent', () => { expect(router.navigate).toHaveBeenCalledWith(['/edit', 123]) }) }) + describe('when asking for record duplication', () => { const uniqueIdentifier = 123 const singleRecord = { @@ -167,6 +222,7 @@ describe('SearchRecordsComponent', () => { expect(router.navigate).toHaveBeenCalledWith(['/duplicate', 123]) }) }) + describe('when click on pagination', () => { beforeEach(() => { pagination.newCurrentPageEvent.emit(3) diff --git a/apps/metadata-editor/src/app/records/search-records/search-records-list.component.ts b/apps/metadata-editor/src/app/records/search-records/search-records-list.component.ts index a6a0d5af3d..be862df39e 100644 --- a/apps/metadata-editor/src/app/records/search-records/search-records-list.component.ts +++ b/apps/metadata-editor/src/app/records/search-records/search-records-list.component.ts @@ -1,5 +1,12 @@ import { CommonModule } from '@angular/common' -import { Component } from '@angular/core' +import { + ChangeDetectorRef, + Component, + ElementRef, + TemplateRef, + ViewChild, + ViewContainerRef, +} from '@angular/core' import { ResultsTableContainerComponent, SearchFacade, @@ -14,6 +21,14 @@ import { Observable } from 'rxjs' import { UiElementsModule } from '@geonetwork-ui/ui/elements' import { UiInputsModule } from '@geonetwork-ui/ui/inputs' import { MatIconModule } from '@angular/material/icon' +import { + CdkConnectedOverlay, + CdkOverlayOrigin, + Overlay, + OverlayRef, +} from '@angular/cdk/overlay' +import { TemplatePortal } from '@angular/cdk/portal' +import { ImportRecordComponent } from '@geonetwork-ui/feature/editor' @Component({ selector: 'md-editor-search-records-list', @@ -28,32 +43,88 @@ import { MatIconModule } from '@angular/material/icon' UiElementsModule, UiInputsModule, MatIconModule, + ImportRecordComponent, + CdkOverlayOrigin, + CdkConnectedOverlay, ], }) export class SearchRecordsComponent { + @ViewChild('importRecordButton', { read: ElementRef }) + private importRecordButton!: ElementRef + @ViewChild('template') template!: TemplateRef + private overlayRef!: OverlayRef + searchText$: Observable = this.searchFacade.searchFilters$.pipe( map((filters) => ('any' in filters ? (filters['any'] as string) : null)) ) + isImportMenuOpen = false + constructor( private router: Router, public searchFacade: SearchFacade, - public searchService: SearchService + public searchService: SearchService, + private overlay: Overlay, + private viewContainerRef: ViewContainerRef, + private cdr: ChangeDetectorRef ) { this.searchFacade.setPageSize(15) this.searchFacade.resetSearch() } editRecord(record: CatalogRecord) { - this.router.navigate(['/edit', record.uniqueIdentifier]) + this.router + .navigate(['/edit', record.uniqueIdentifier]) + .catch((err) => console.error(err)) } duplicateRecord(record: CatalogRecord) { - this.router.navigate(['/duplicate', record.uniqueIdentifier]) + this.router + .navigate(['/duplicate', record.uniqueIdentifier]) + .catch((err) => console.error(err)) } createRecord() { - this.router.navigate(['/create']) + this.router.navigate(['/create']).catch((err) => console.error(err)) + } + + duplicateExternalRecord() { + this.isImportMenuOpen = true + + const positionStrategy = this.overlay + .position() + .flexibleConnectedTo(this.importRecordButton) + .withPositions([ + { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top', + }, + ]) + + this.overlayRef = this.overlay.create({ + hasBackdrop: true, + backdropClass: 'cdk-overlay-transparent-backdrop', + positionStrategy: positionStrategy, + scrollStrategy: this.overlay.scrollStrategies.reposition(), + }) + + const portal = new TemplatePortal(this.template, this.viewContainerRef) + + this.overlayRef.attach(portal) + + this.overlayRef.backdropClick().subscribe(() => { + this.closeImportMenu() + }) + } + + closeImportMenu() { + if (this.overlayRef) { + this.isImportMenuOpen = false + this.overlayRef.dispose() + this.cdr.markForCheck() + } } } diff --git a/apps/metadata-editor/src/styles.css b/apps/metadata-editor/src/styles.css index 29fec93421..6352aa4946 100644 --- a/apps/metadata-editor/src/styles.css +++ b/apps/metadata-editor/src/styles.css @@ -22,6 +22,10 @@ body { @apply text-2xl px-9 py-3; } +.cdk-overlay-transparent-backdrop { + @apply bg-transparent; +} + .mat-mdc-button-base { line-height: normal; } diff --git a/libs/api/metadata-converter/src/index.ts b/libs/api/metadata-converter/src/index.ts index 169d8dfcb6..3ce8f4d066 100644 --- a/libs/api/metadata-converter/src/index.ts +++ b/libs/api/metadata-converter/src/index.ts @@ -2,3 +2,4 @@ export * from './lib/iso19139' export * from './lib/iso19115-3' export * from './lib/find-converter' export * from './lib/gn4' +export * from './lib/xml-utils' diff --git a/libs/api/metadata-converter/src/lib/xml-utils.spec.ts b/libs/api/metadata-converter/src/lib/xml-utils.spec.ts index 1d7957feb9..4a4815c547 100644 --- a/libs/api/metadata-converter/src/lib/xml-utils.spec.ts +++ b/libs/api/metadata-converter/src/lib/xml-utils.spec.ts @@ -1,11 +1,14 @@ import { XmlElement } from '@rgrove/parse-xml' import { + assertValidXml, + createDocument, getRootElement, parseXmlString, readText, renameElements, xmlToString, } from './xml-utils' +import { simpleDatasetRecordAsXmlFixture } from '@geonetwork-ui/common/fixtures' describe('xml utils', () => { describe('xmlToString', () => { @@ -162,4 +165,47 @@ world expect(readText()(null)).toEqual(null) }) }) + + describe('createDocument', () => { + it('succeeds when custom namespaces are present inside the tree', () => { + const originalDoc = parseXmlString(` + + + + + + + EPSG:2154 + + + + + +`) + const rootEl = getRootElement(originalDoc) + const newDoc = createDocument(rootEl) + expect(newDoc).toBeTruthy() + }) + }) + + describe('parseAndValidateXml', () => { + it('should parse valid XML without errors', () => { + const validXml = simpleDatasetRecordAsXmlFixture() + const xmlDoc = assertValidXml(validXml) + + expect(xmlDoc).toBeDefined() + expect(xmlDoc.querySelector('mdb\\:MD_Metadata')).toBeTruthy() + expect( + xmlDoc.querySelector('gco\\:CharacterString')?.textContent + ).toContain('my-dataset-001') + }) + + it('should throw an error for invalid XML', () => { + const invalidXml = `Invalid XML assertValidXml(invalidXml)).toThrow( + new Error('File is not a valid XML.') + ) + }) + }) }) diff --git a/libs/api/metadata-converter/src/lib/xml-utils.ts b/libs/api/metadata-converter/src/lib/xml-utils.ts index 1031a709a8..1e2b70f563 100644 --- a/libs/api/metadata-converter/src/lib/xml-utils.ts +++ b/libs/api/metadata-converter/src/lib/xml-utils.ts @@ -36,7 +36,8 @@ export function createDocument(rootEl: XmlElement): XmlDocument { if (namespace === 'xmlns' || namespace === null) return if (rootEl.attributes[`xmlns:${namespace}`]) return if (!NAMESPACES[namespace]) { - throw new Error(`No known URI for namespace ${namespace}`) + // the namespace is unknown but it might still be declared correctly: ignore it + return } rootEl.attributes[`xmlns:${namespace}`] = NAMESPACES[namespace] } @@ -456,3 +457,21 @@ export function renameElements( doReplace(rootElement) return rootElement } + +/** + * This function use the DOMParser to check if the given xmlString is a valid XML file or throw an error + * (Generated by chatGPT) + * @param xmlString + */ +export function assertValidXml(xmlString: string): Document { + const parser = new DOMParser() + const xmlDoc = parser.parseFromString(xmlString, 'application/xml') + const parserError = xmlDoc.querySelector('parsererror') + + if (parserError) { + console.error(parserError) + throw new Error('File is not a valid XML.') + } + + return xmlDoc +} 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 60de0e6766..64105393e7 100644 --- a/libs/api/repository/src/lib/gn4/gn4-repository.spec.ts +++ b/libs/api/repository/src/lib/gn4/gn4-repository.spec.ts @@ -5,7 +5,7 @@ import { } from '@geonetwork-ui/data-access/gn4' import { firstValueFrom, lastValueFrom, of, throwError } from 'rxjs' import { ElasticsearchService } from './elasticsearch' -import { TestBed } from '@angular/core/testing' +import { fakeAsync, TestBed, tick } from '@angular/core/testing' import { EsSearchResponse, Gn4Converter, @@ -22,6 +22,10 @@ import { import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' import { map } from 'rxjs/operators' import { HttpErrorResponse } from '@angular/common/http' +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing' class Gn4MetadataMapperMock { readRecords = jest.fn((records) => @@ -84,6 +88,7 @@ class RecordsApiServiceMock { }, }) ) + deleteRecord = jest.fn(() => of({})) } describe('Gn4Repository', () => { @@ -91,9 +96,11 @@ describe('Gn4Repository', () => { let gn4Helper: ElasticsearchService let gn4SearchApi: SearchApiService let gn4RecordsApi: RecordsApiService + let httpTestingController: HttpTestingController beforeEach(() => { TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], providers: [ Gn4Repository, { @@ -118,7 +125,14 @@ describe('Gn4Repository', () => { gn4Helper = TestBed.inject(ElasticsearchService) gn4SearchApi = TestBed.inject(SearchApiService) gn4RecordsApi = TestBed.inject(RecordsApiService) + httpTestingController = TestBed.inject(HttpTestingController) + }) + + afterEach(() => { + // Verify that no other requests are outstanding + httpTestingController.verify() }) + it('creates', () => { expect(repository).toBeTruthy() }) @@ -536,4 +550,149 @@ describe('Gn4Repository', () => { ]) }) }) + + describe('importRecordFromExternalFileUrlAsDraft', () => { + const recordDownloadUrl = 'https://example.com/record/xml' + const mockXml = simpleDatasetRecordAsXmlFixture() + let tempId: string + + it('should fetch the external record and save it as a draft', fakeAsync(() => { + repository.duplicateExternalRecord(recordDownloadUrl).subscribe((id) => { + tempId = id + + expect(tempId).toMatch(/^TEMP-ID-\d+$/) + }) + + const req = httpTestingController.expectOne(recordDownloadUrl) + + expect(req.request.headers.get('Accept')).toEqual( + 'text/xml,application/xml' + ) + expect(req.request.method).toEqual('GET') + + req.flush(mockXml) + + tick() + })) + + it('should handle an error response when fetching the external record', fakeAsync(() => { + let errorResponse: any + + repository.duplicateExternalRecord(recordDownloadUrl).subscribe({ + error: (error) => { + errorResponse = error + }, + }) + + const req = httpTestingController.expectOne(recordDownloadUrl) + + req.flush('Error fetching record', { + status: 404, + statusText: 'Not Found', + }) + + tick() + + expect(errorResponse).toBeDefined() + expect(errorResponse.status).toBe(404) + expect(errorResponse.statusText).toBe('Not Found') + })) + }) + + describe('deleteRecord', () => { + it('calls the API to delete the record by unique identifier', fakeAsync(() => { + repository.deleteRecord('1234-5678').subscribe() + + // Simulate async passage of time + tick() + + // Ensure the API method was called correctly + expect(gn4RecordsApi.deleteRecord).toHaveBeenCalledWith('1234-5678') + })) + }) + + describe('saveRecordAsDraft', () => { + beforeEach(async () => { + await lastValueFrom( + repository.saveRecordAsDraft({ + ...simpleDatasetRecordFixture(), + uniqueIdentifier: 'DRAFT-123', + }) + ) + }) + + it('saves the record to localStorage with the correct key', () => { + const hasDraft = repository.recordHasDraft('DRAFT-123') + expect(hasDraft).toBe(true) + }) + + it('emits a draft changed notification', async () => { + const draftsChangedSpy = jest.spyOn(repository._draftsChanged, 'next') + + await lastValueFrom( + repository.saveRecordAsDraft({ + ...simpleDatasetRecordFixture(), + uniqueIdentifier: 'DRAFT-456', + }) + ) + + expect(draftsChangedSpy).toHaveBeenCalled() + }) + }) + + describe('generateTemporaryId', () => { + it('generates a temporary ID with the correct prefix', () => { + const tempId = repository.generateTemporaryId() + expect(tempId).toMatch(/^TEMP-ID-\d+$/) + }) + }) + + describe('clearRecordDraft', () => { + beforeEach(async () => { + await lastValueFrom( + repository.saveRecordAsDraft({ + ...simpleDatasetRecordFixture(), + uniqueIdentifier: 'DRAFT-123', + }) + ) + repository.clearRecordDraft('DRAFT-123') + }) + + it('removes the draft from localStorage', () => { + const hasDraft = repository.recordHasDraft('DRAFT-123') + expect(hasDraft).toBe(false) + }) + + it('emits a draft changed notification after clearing', () => { + const draftsChangedSpy = jest.spyOn(repository._draftsChanged, 'next') + repository.clearRecordDraft('DRAFT-123') + expect(draftsChangedSpy).toHaveBeenCalled() + }) + }) + + describe('recordHasDraft', () => { + it('returns true if the draft exists', async () => { + await lastValueFrom( + repository.saveRecordAsDraft({ + ...simpleDatasetRecordFixture(), + uniqueIdentifier: 'DRAFT-123', + }) + ) + expect(repository.recordHasDraft('DRAFT-123')).toBe(true) + }) + + it('returns false if the draft does not exist', () => { + expect(repository.recordHasDraft('NON_EXISTENT_DRAFT')).toBe(false) + }) + }) + + describe('isRecordNotYetSaved', () => { + it('returns true if the record has a temporary ID', () => { + expect(repository.isRecordNotYetSaved('TEMP-ID-12345')).toBe(true) + }) + + it('returns false if the record does not have a temporary ID', () => { + expect(repository.isRecordNotYetSaved('1234-5678')).toBe(false) + }) + }) }) diff --git a/libs/api/repository/src/lib/gn4/gn4-repository.ts b/libs/api/repository/src/lib/gn4/gn4-repository.ts index a31688b218..9b6fd06a30 100644 --- a/libs/api/repository/src/lib/gn4/gn4-repository.ts +++ b/libs/api/repository/src/lib/gn4/gn4-repository.ts @@ -6,6 +6,7 @@ import { import { ElasticsearchService } from './elasticsearch' import { combineLatest, + exhaustMap, from, Observable, of, @@ -25,22 +26,30 @@ import { } from '@geonetwork-ui/common/domain/model/search' import { catchError, map, tap } from 'rxjs/operators' import { + assertValidXml, findConverterForDocument, Gn4Converter, Gn4SearchResults, Iso19139Converter, } from '@geonetwork-ui/api/metadata-converter' import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' -import { HttpErrorResponse } from '@angular/common/http' +import { + HttpClient, + HttpErrorResponse, + HttpHeaders, +} from '@angular/common/http' const TEMPORARY_ID_PREFIX = 'TEMP-ID-' +export type RecordAsXml = string + @Injectable() export class Gn4Repository implements RecordsRepositoryInterface { _draftsChanged = new Subject() draftsChanged$ = this._draftsChanged.asObservable() constructor( + private httpClient: HttpClient, private gn4SearchApi: SearchApiService, private gn4SearchHelper: ElasticsearchService, private gn4Mapper: Gn4Converter, @@ -140,6 +149,7 @@ export class Gn4Repository implements RecordsRepositoryInterface { ) ) } + aggregate(params: AggregationsParams): Observable { // if aggregations are empty, return an empty object right away if (Object.keys(params).length === 0) return of({}) @@ -184,44 +194,12 @@ export class Gn4Repository implements RecordsRepositoryInterface { ) } - /** - * Returns null if the record is not found - */ - private loadRecordAsXml(uniqueIdentifier: string): Observable { - return this.gn4RecordsApi - .getRecordAs( - uniqueIdentifier, - undefined, - false, - undefined, - undefined, - undefined, - 'application/xml', - 'response', - undefined, - { httpHeaderAccept: 'text/xml,application/xml' as 'application/xml' } // this is to make sure that the response is parsed as text - ) - .pipe( - map((response) => response.body), - catchError((error: HttpErrorResponse) => - error.status === 404 ? of(null) : throwError(() => error) - ) - ) - } - - private getLocalStorageKeyForRecord(uniqueIdentifier: string) { - return `geonetwork-ui-draft-${uniqueIdentifier}` - } - openRecordForEdition( uniqueIdentifier: string ): Observable<[CatalogRecord, string, boolean] | null> { - const draft$ = of( - window.localStorage.getItem( - this.getLocalStorageKeyForRecord(uniqueIdentifier) - ) - ) - const recordAsXml$ = this.loadRecordAsXml(uniqueIdentifier) + const draft$ = of(this.getRecordFromLocalStorage(uniqueIdentifier)) + const recordAsXml$ = this.getRecordAsXml(uniqueIdentifier) + return combineLatest([draft$, recordAsXml$]).pipe( switchMap(([draft, recordAsXml]) => { const xml = draft ?? recordAsXml @@ -239,34 +217,27 @@ export class Gn4Repository implements RecordsRepositoryInterface { openRecordForDuplication( uniqueIdentifier: string ): Observable<[CatalogRecord, string, false] | null> { - return this.loadRecordAsXml(uniqueIdentifier).pipe( - switchMap(async (recordAsXml) => { - const converter = findConverterForDocument(recordAsXml) - const record = await converter.readRecord(recordAsXml) + return this.getRecordAsXml(uniqueIdentifier).pipe( + switchMap(async (fetchedRecordAsXml) => { + const converter = findConverterForDocument(fetchedRecordAsXml) + const record = await converter.readRecord(fetchedRecordAsXml) + record.uniqueIdentifier = `${TEMPORARY_ID_PREFIX}${Date.now()}` record.title = `${record.title} (Copy)` - const xml = await converter.writeRecord(record, recordAsXml) - window.localStorage.setItem( - this.getLocalStorageKeyForRecord(record.uniqueIdentifier), - xml + + const recordAsXml = await converter.writeRecord( + record, + fetchedRecordAsXml ) + + this.saveRecordToLocalStorage(recordAsXml, record.uniqueIdentifier) this._draftsChanged.next() - return [record, xml, false] as [CatalogRecord, string, false] + + return [record, recordAsXml, false] as [CatalogRecord, string, false] }) ) } - private serializeRecordToXml( - record: CatalogRecord, - referenceRecordSource?: string - ): Observable { - // if there's a reference record, use that standard; otherwise, use iso19139 - const converter = referenceRecordSource - ? findConverterForDocument(referenceRecordSource) - : new Iso19139Converter() - return from(converter.writeRecord(record, referenceRecordSource)) - } - saveRecord( record: CatalogRecord, referenceRecordSource?: string @@ -301,6 +272,32 @@ export class Gn4Repository implements RecordsRepositoryInterface { ) } + duplicateExternalRecord(recordDownloadUrl: string): Observable { + return this.getExternalRecordAsXml(recordDownloadUrl).pipe( + exhaustMap(async (fetchedRecordAsXml: string) => { + const converter = findConverterForDocument(fetchedRecordAsXml) + const record = await converter.readRecord(fetchedRecordAsXml) + const tempId = this.generateTemporaryId() + + record.title = `${record.title} (Copy)` + record.uniqueIdentifier = tempId + + const recordAsXml = await converter.writeRecord( + record, + fetchedRecordAsXml + ) + + this.saveRecordToLocalStorage(recordAsXml, record.uniqueIdentifier) + this._draftsChanged.next() + + return tempId + }), + catchError((error: HttpErrorResponse) => { + return throwError(() => error) + }) + ) + } + deleteRecord(uniqueIdentifier: string): Observable { return this.gn4RecordsApi.deleteRecord(uniqueIdentifier) } @@ -315,28 +312,19 @@ export class Gn4Repository implements RecordsRepositoryInterface { ): Observable { return this.serializeRecordToXml(record, referenceRecordSource).pipe( tap((recordXml) => { - window.localStorage.setItem( - this.getLocalStorageKeyForRecord(record.uniqueIdentifier), - recordXml - ) + this.saveRecordToLocalStorage(recordXml, record.uniqueIdentifier) this._draftsChanged.next() }) ) } clearRecordDraft(uniqueIdentifier: string): void { - window.localStorage.removeItem( - this.getLocalStorageKeyForRecord(uniqueIdentifier) - ) + this.removeRecordFromLocalStorage(uniqueIdentifier) this._draftsChanged.next() } recordHasDraft(uniqueIdentifier: string): boolean { - return ( - window.localStorage.getItem( - this.getLocalStorageKeyForRecord(uniqueIdentifier) - ) !== null - ) + return this.getRecordFromLocalStorage(uniqueIdentifier) !== null } isRecordNotYetSaved(uniqueIdentifier: string): boolean { @@ -356,4 +344,80 @@ export class Gn4Repository implements RecordsRepositoryInterface { ) ) } + + private getRecordAsXml(uniqueIdentifier: string): Observable { + return this.gn4RecordsApi + .getRecordAs( + uniqueIdentifier, + undefined, + false, + undefined, + undefined, + undefined, + 'application/xml', + 'response', + undefined, + { httpHeaderAccept: 'text/xml,application/xml' as 'application/xml' } // this is to make sure that the response is parsed as text + ) + .pipe( + map((response) => response.body), + catchError((error: HttpErrorResponse) => + error.status === 404 ? of(null) : throwError(() => error) + ) + ) + } + + private serializeRecordToXml( + record: CatalogRecord, + referenceRecordSource?: string + ): Observable { + // if there's a reference record, use that standard; otherwise, use iso19139 + const converter = referenceRecordSource + ? findConverterForDocument(referenceRecordSource) + : new Iso19139Converter() + return from(converter.writeRecord(record, referenceRecordSource)) + } + + private getExternalRecordAsXml( + recordDownloadUrl: string + ): Observable { + let headers = new HttpHeaders() + const responseType_ = 'text' + headers = headers.set('Accept', 'text/xml,application/xml') + + return this.httpClient + .get(recordDownloadUrl, { + responseType: responseType_, + headers: headers, + observe: 'body', + }) + .pipe( + map((recordAsXmlFile) => { + assertValidXml(recordAsXmlFile) + + return recordAsXmlFile + }) + ) + } + + private getLocalStorageKeyForRecord(recordId: string): string { + return `geonetwork-ui-draft-${recordId}` // Never change this prefix as it is a breaking change + } + + private saveRecordToLocalStorage(recordAsXml: RecordAsXml, recordId: string) { + window.localStorage.setItem( + this.getLocalStorageKeyForRecord(recordId), + recordAsXml + ) + } + + private getRecordFromLocalStorage(recordId: string): RecordAsXml { + return window.localStorage.getItem( + this.getLocalStorageKeyForRecord(recordId) + ) + } + + private removeRecordFromLocalStorage(recordId: string): void { + window.localStorage.removeItem(this.getLocalStorageKeyForRecord(recordId)) + } } 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 e254465afe..e17c833407 100644 --- a/libs/common/domain/src/lib/repository/records-repository.interface.ts +++ b/libs/common/domain/src/lib/repository/records-repository.interface.ts @@ -52,6 +52,16 @@ export abstract class RecordsRepositoryInterface { referenceRecordSource?: string ): Observable + /** + * Try to duplicate the external record from given url. If it suceed, then it will save the record as draft and return its temporary id. + * + * @param recordDownloadUrl + * @returns Observable + */ + abstract duplicateExternalRecord( + recordDownloadUrl: string + ): Observable + /** * @param uniqueIdentifier * @returns Observable Returns when record is deleted diff --git a/libs/feature/editor/src/index.ts b/libs/feature/editor/src/index.ts index 5e6dd85211..0f0b938728 100644 --- a/libs/feature/editor/src/index.ts +++ b/libs/feature/editor/src/index.ts @@ -5,6 +5,7 @@ export * from './lib/+state/editor.reducer' export * from './lib/+state/editor.actions' export * from './lib/feature-editor.module' export * from './lib/services/editor.service' +export * from './lib/components/import-record/import-record.component' export * from './lib/components/record-form/record-form.component' export * from './lib/components/wizard/wizard.component' export * from './lib/components/wizard-field/wizard-field.component' diff --git a/libs/feature/editor/src/lib/components/import-record/import-record.component.css b/libs/feature/editor/src/lib/components/import-record/import-record.component.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/feature/editor/src/lib/components/import-record/import-record.component.html b/libs/feature/editor/src/lib/components/import-record/import-record.component.html new file mode 100644 index 0000000000..5b9492a767 --- /dev/null +++ b/libs/feature/editor/src/lib/components/import-record/import-record.component.html @@ -0,0 +1,43 @@ + + +
+
    +
  • + + {{ menuItem.icon }} {{ menuItem.label }} +
  • +
+
+
+ +
+
+ + arrow_back + + {{ externalImportBackLabel }} +
+ +
+
+
diff --git a/libs/feature/editor/src/lib/components/import-record/import-record.component.spec.ts b/libs/feature/editor/src/lib/components/import-record/import-record.component.spec.ts new file mode 100644 index 0000000000..1a85b10858 --- /dev/null +++ b/libs/feature/editor/src/lib/components/import-record/import-record.component.spec.ts @@ -0,0 +1,125 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { ImportRecordComponent } from './import-record.component' +import { ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core' +import { Router } from '@angular/router' +import { TranslateModule, TranslateService } from '@ngx-translate/core' +import { NotificationsService } from '@geonetwork-ui/feature/notifications' +import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface' +import { of, throwError } from 'rxjs' +import { MockBuilder, MockComponent, MockModule, MockProviders } from 'ng-mocks' + +describe('ImportRecordComponent', () => { + let component: ImportRecordComponent + let fixture: ComponentFixture + let notificationsService: NotificationsService + let translateService: TranslateService + let router: Router + let recordsRepository: RecordsRepositoryInterface + + beforeEach(() => { + return MockBuilder(ImportRecordComponent) + }) + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + MockComponent(ImportRecordComponent), + MockModule(TranslateModule.forRoot()), + ], + providers: [ + MockProviders( + Router, + TranslateService, + NotificationsService, + RecordsRepositoryInterface, + ChangeDetectorRef + ), + ], + }) + .overrideComponent(ImportRecordComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents() + + fixture = TestBed.createComponent(ImportRecordComponent) + + recordsRepository = TestBed.inject(RecordsRepositoryInterface) + notificationsService = TestBed.inject(NotificationsService) + translateService = TestBed.inject(TranslateService) + router = TestBed.inject(Router) + + component = fixture.componentInstance + + translateService.instant = jest.fn( + (translationKey: string) => translationKey + ) + router.navigate = jest.fn().mockReturnValue(Promise.resolve(true)) + + fixture.detectChanges() + }) + + it('should create the component', () => { + expect(component).toBeTruthy() + }) + + it('should successfully import record and navigate on success', () => { + const mockUrl = 'https://example.com/file' + const mockRecordTempId = '12345' + recordsRepository.duplicateExternalRecord = jest + .fn() + .mockReturnValue(of(mockRecordTempId)) + + const closeMenuSpy = jest.spyOn(component.closeImportMenu, 'next') + + component.importRecord(mockUrl) + + expect(recordsRepository.duplicateExternalRecord).toHaveBeenCalledWith( + mockUrl + ) + + expect(notificationsService.showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + title: 'editor.record.importFromExternalFile.success.title', + text: `editor.record.importFromExternalFile.success.body`, + }), + 2500 + ) + + expect(router.navigate).toHaveBeenCalledWith(['/edit', mockRecordTempId]) + expect(closeMenuSpy).toHaveBeenCalled() + }) + + it('should handle error when importRecord fails', () => { + const mockUrl = 'https://example.com/file' + const mockError = 'Import failed' + recordsRepository.duplicateExternalRecord = jest + .fn() + .mockReturnValue(throwError(() => mockError)) + + component.importRecord(mockUrl) + + expect(recordsRepository.duplicateExternalRecord).toHaveBeenCalledWith( + mockUrl + ) + + expect(notificationsService.showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + title: 'editor.record.importFromExternalFile.failure.title', + text: `editor.record.importFromExternalFile.failure.body `, + }), + 2500 + ) + + expect(component.isRecordImportInProgress).toBe(false) + }) + + it('should emit closeImportMenu when closing the menu', () => { + const closeMenuSpy = jest.spyOn(component.closeImportMenu, 'next') + component.closeImportMenu.next() + expect(closeMenuSpy).toHaveBeenCalled() + }) +}) diff --git a/libs/feature/editor/src/lib/components/import-record/import-record.component.stories.ts b/libs/feature/editor/src/lib/components/import-record/import-record.component.stories.ts new file mode 100644 index 0000000000..1681f7aacb --- /dev/null +++ b/libs/feature/editor/src/lib/components/import-record/import-record.component.stories.ts @@ -0,0 +1,60 @@ +import { + applicationConfig, + componentWrapperDecorator, + Meta, + moduleMetadata, + StoryObj, +} from '@storybook/angular' +import { ImportRecordComponent } from './import-record.component' +import { BrowserAnimationsModule } from '@angular/platform-browser/animations' +import { importProvidersFrom } from '@angular/core' +import { MatIconModule } from '@angular/material/icon' +import { CommonModule } from '@angular/common' +import { ButtonComponent, UrlInputComponent } from '@geonetwork-ui/ui/inputs' +import { ThumbnailComponent } from '@geonetwork-ui/ui/elements' +import { UtilI18nModule } from '@geonetwork-ui/util/i18n' +import { TranslateModule } from '@ngx-translate/core' +import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface' +import { of } from 'rxjs' + +class MockRecordsRepository { + importRecordFromExternalFileUrlAsDraft(url: string) { + return of('mockedRecordTempId') + } +} + +export default { + title: 'Elements/ImportRecordComponent', + component: ImportRecordComponent, + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + MatIconModule, + ButtonComponent, + ThumbnailComponent, + UrlInputComponent, + ], + providers: [ + { + provide: RecordsRepositoryInterface, + useClass: MockRecordsRepository, + }, + ], + }), + applicationConfig({ + providers: [ + importProvidersFrom(BrowserAnimationsModule), + importProvidersFrom(UtilI18nModule), + importProvidersFrom(TranslateModule.forRoot()), + ], + }), + componentWrapperDecorator( + (story) => `
${story}
` + ), + ], +} as Meta + +export const Primary: StoryObj = { + args: {}, +} diff --git a/libs/feature/editor/src/lib/components/import-record/import-record.component.ts b/libs/feature/editor/src/lib/components/import-record/import-record.component.ts new file mode 100644 index 0000000000..511f63bd21 --- /dev/null +++ b/libs/feature/editor/src/lib/components/import-record/import-record.component.ts @@ -0,0 +1,129 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + Output, +} from '@angular/core' +import { MatIconModule } from '@angular/material/icon' +import { CommonModule } from '@angular/common' +import { ButtonComponent, UrlInputComponent } from '@geonetwork-ui/ui/inputs' +import { ThumbnailComponent } from '@geonetwork-ui/ui/elements' +import { TranslateService } from '@ngx-translate/core' +import { NotificationsService } from '@geonetwork-ui/feature/notifications' +import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface' +import { Router } from '@angular/router' + +interface ImportMenuItems { + label: string + icon: string + action: () => any + dataTest: string +} + +type ImportMenuPage = 'mainMenu' | 'importExternalFile' + +@Component({ + selector: 'gn-ui-import-record', + templateUrl: './import-record.component.html', + styleUrls: ['./import-record.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + CommonModule, + MatIconModule, + ButtonComponent, + ThumbnailComponent, + UrlInputComponent, + ], +}) +export class ImportRecordComponent { + @Output() closeImportMenu = new EventEmitter() + + importMenuItems: ImportMenuItems[] = [ + { + label: this.translateService.instant('dashboard.importRecord.useModel'), + icon: 'highlight', + action: () => null, + dataTest: 'useAModelButton', + }, + { + label: this.translateService.instant( + 'dashboard.importRecord.importExternal' + ), + icon: 'cloud_download', + action: this.displayImportExternal.bind(this), + dataTest: 'importFromUrlButton', + }, + ] + + isRecordImportInProgress = false + + sectionDisplayed: ImportMenuPage = 'mainMenu' + + externalImportBackLabel = this.translateService.instant( + 'dashboard.importRecord.importExternalLabel' + ) + + constructor( + private router: Router, + private translateService: TranslateService, + private cdr: ChangeDetectorRef, + private notificationsService: NotificationsService, + private recordsRepository: RecordsRepositoryInterface + ) {} + + displayMainMenu() { + this.sectionDisplayed = 'mainMenu' + this.cdr.markForCheck() + } + + displayImportExternal() { + this.sectionDisplayed = 'importExternalFile' + this.cdr.markForCheck() + } + + importRecord(url: string) { + this.isRecordImportInProgress = true + + this.recordsRepository.duplicateExternalRecord(url).subscribe({ + next: (recordTempId) => { + if (recordTempId) { + this.notificationsService.showNotification( + { + type: 'success', + title: this.translateService.instant( + 'editor.record.importFromExternalFile.success.title' + ), + text: `${this.translateService.instant( + 'editor.record.importFromExternalFile.success.body' + )}`, + }, + 2500 + ) + + this.router + .navigate(['/edit', recordTempId]) + .catch((err) => console.error(err)) + } + this.closeImportMenu.next() + }, + error: (error) => { + this.notificationsService.showNotification( + { + type: 'error', + title: this.translateService.instant( + 'editor.record.importFromExternalFile.failure.title' + ), + text: `${this.translateService.instant( + 'editor.record.importFromExternalFile.failure.body' + )} ${error.message ?? ''}`, + }, + 2500 + ) + this.isRecordImportInProgress = false + this.cdr.markForCheck() + }, + }) + } +} diff --git a/libs/ui/inputs/src/index.ts b/libs/ui/inputs/src/index.ts index 173648678f..2fd16aa78e 100644 --- a/libs/ui/inputs/src/index.ts +++ b/libs/ui/inputs/src/index.ts @@ -19,6 +19,7 @@ export * from './lib/star-toggle/star-toggle.component' export * from './lib/text-area/text-area.component' export * from './lib/text-input/text-input.component' export * from './lib/ui-inputs.module' +export * from './lib/url-input/url-input.component' export * from './lib/viewport-intersector/viewport-intersector.component' export * from './lib/previous-next-buttons/previous-next-buttons.component' export * from './lib/switch-toggle/switch-toggle.component' diff --git a/translations/de.json b/translations/de.json index 0ae7eccdb4..348cebb372 100644 --- a/translations/de.json +++ b/translations/de.json @@ -24,6 +24,10 @@ "dashboard.catalog.discussion": "Diskussion", "dashboard.catalog.thesaurus": "Thesaurus", "dashboard.createRecord": "Neuer Eintrag", + "dashboard.importRecord": "", + "dashboard.importRecord.importExternal": "", + "dashboard.importRecord.importExternalLabel": "", + "dashboard.importRecord.useModel": "", "dashboard.labels.catalog": "Katalog", "dashboard.labels.mySpace": "Mein Bereich", "dashboard.records.all": "Katalog", @@ -251,6 +255,10 @@ "editor.record.form.temporalExtents.date": "Datum", "editor.record.form.temporalExtents.range": "Datumsbereich", "editor.record.form.updateFrequency.planned": "Die Daten sollten regelmäßig aktualisiert werden.", + "editor.record.importFromExternalFile.failure.body": "", + "editor.record.importFromExternalFile.failure.title": "", + "editor.record.importFromExternalFile.success.body": "", + "editor.record.importFromExternalFile.success.title": "", "editor.record.loadError.body": "Der Datensatz konnte nicht geladen werden:", "editor.record.loadError.closeMessage": "Verstanden", "editor.record.loadError.title": "Fehler beim Laden des Datensatzes", diff --git a/translations/en.json b/translations/en.json index 9fc876c63f..9659f2a0bd 100644 --- a/translations/en.json +++ b/translations/en.json @@ -24,6 +24,10 @@ "dashboard.catalog.discussion": "Discussion", "dashboard.catalog.thesaurus": "Thesaurus", "dashboard.createRecord": "New record", + "dashboard.importRecord": "Import", + "dashboard.importRecord.importExternal": "Import an external file", + "dashboard.importRecord.importExternalLabel": "External file URL", + "dashboard.importRecord.useModel": "Use a model", "dashboard.labels.catalog": "Catalog", "dashboard.labels.mySpace": "My space", "dashboard.records.all": "Metadata records", @@ -251,6 +255,10 @@ "editor.record.form.temporalExtents.date": "Date", "editor.record.form.temporalExtents.range": "Date range", "editor.record.form.updateFrequency.planned": "The data should be updated regularly.", + "editor.record.importFromExternalFile.failure.body": "Failure", + "editor.record.importFromExternalFile.failure.title": "The import of the record has failed: ", + "editor.record.importFromExternalFile.success.body": "Import succesful", + "editor.record.importFromExternalFile.success.title": "The record has been succefuly imported.", "editor.record.loadError.body": "The record could not be loaded:", "editor.record.loadError.closeMessage": "Understood", "editor.record.loadError.title": "Error loading record", diff --git a/translations/es.json b/translations/es.json index 6f06be151f..1c68c344c1 100644 --- a/translations/es.json +++ b/translations/es.json @@ -24,6 +24,10 @@ "dashboard.catalog.discussion": "", "dashboard.catalog.thesaurus": "", "dashboard.createRecord": "", + "dashboard.importRecord": "", + "dashboard.importRecord.importExternal": "", + "dashboard.importRecord.importExternalLabel": "", + "dashboard.importRecord.useModel": "", "dashboard.labels.catalog": "Catálogo", "dashboard.labels.mySpace": "Mi espacio", "dashboard.records.all": "Catálogo", @@ -251,6 +255,10 @@ "editor.record.form.temporalExtents.date": "", "editor.record.form.temporalExtents.range": "", "editor.record.form.updateFrequency.planned": "", + "editor.record.importFromExternalFile.failure.body": "", + "editor.record.importFromExternalFile.failure.title": "", + "editor.record.importFromExternalFile.success.body": "", + "editor.record.importFromExternalFile.success.title": "", "editor.record.loadError.body": "", "editor.record.loadError.closeMessage": "", "editor.record.loadError.title": "", diff --git a/translations/fr.json b/translations/fr.json index 5c35461f81..94f8e4e9a2 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -24,6 +24,10 @@ "dashboard.catalog.discussion": "Discussions", "dashboard.catalog.thesaurus": "Thesaurus", "dashboard.createRecord": "Nouvel enregistrement", + "dashboard.importRecord": "Importer", + "dashboard.importRecord.importExternal": "Importer une fiche externe", + "dashboard.importRecord.importExternalLabel": "URL de la fiche externe", + "dashboard.importRecord.useModel": "Utiliser un modele", "dashboard.labels.catalog": "Catalogue", "dashboard.labels.mySpace": "Mon espace", "dashboard.records.all": "Catalogue", @@ -251,6 +255,10 @@ "editor.record.form.temporalExtents.date": "Date concernée", "editor.record.form.temporalExtents.range": "Période concernée", "editor.record.form.updateFrequency.planned": "Ces données doivent être mise à jour régulièrement.", + "editor.record.importFromExternalFile.failure.body": "Une erreur est survenue pendant l'import de la fiche: ", + "editor.record.importFromExternalFile.failure.title": "Erreur", + "editor.record.importFromExternalFile.success.body": "L'import de la fiche de metadonnée à été realisée avec succès.", + "editor.record.importFromExternalFile.success.title": "Import reussi", "editor.record.loadError.body": "La fiche n'a pas pu être chargée :", "editor.record.loadError.closeMessage": "Compris", "editor.record.loadError.title": "Erreur lors du chargement", diff --git a/translations/it.json b/translations/it.json index ab4b661dfa..0f3083db62 100644 --- a/translations/it.json +++ b/translations/it.json @@ -24,6 +24,10 @@ "dashboard.catalog.discussion": "", "dashboard.catalog.thesaurus": "", "dashboard.createRecord": "Crea un record", + "dashboard.importRecord": "", + "dashboard.importRecord.importExternal": "", + "dashboard.importRecord.importExternalLabel": "", + "dashboard.importRecord.useModel": "", "dashboard.labels.catalog": "Catalogo", "dashboard.labels.mySpace": "Il mio spazio", "dashboard.records.all": "Catalogo", @@ -251,6 +255,10 @@ "editor.record.form.temporalExtents.date": "", "editor.record.form.temporalExtents.range": "", "editor.record.form.updateFrequency.planned": "", + "editor.record.importFromExternalFile.failure.body": "", + "editor.record.importFromExternalFile.failure.title": "", + "editor.record.importFromExternalFile.success.body": "", + "editor.record.importFromExternalFile.success.title": "", "editor.record.loadError.body": "", "editor.record.loadError.closeMessage": "", "editor.record.loadError.title": "", diff --git a/translations/nl.json b/translations/nl.json index 8a3fd691e6..cb16537ecc 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -24,6 +24,10 @@ "dashboard.catalog.discussion": "", "dashboard.catalog.thesaurus": "", "dashboard.createRecord": "", + "dashboard.importRecord": "", + "dashboard.importRecord.importExternal": "", + "dashboard.importRecord.importExternalLabel": "", + "dashboard.importRecord.useModel": "", "dashboard.labels.catalog": "Catalogus", "dashboard.labels.mySpace": "Mijn ruimte", "dashboard.records.all": "Catalogus", @@ -251,6 +255,10 @@ "editor.record.form.temporalExtents.date": "", "editor.record.form.temporalExtents.range": "", "editor.record.form.updateFrequency.planned": "", + "editor.record.importFromExternalFile.failure.body": "", + "editor.record.importFromExternalFile.failure.title": "", + "editor.record.importFromExternalFile.success.body": "", + "editor.record.importFromExternalFile.success.title": "", "editor.record.loadError.body": "", "editor.record.loadError.closeMessage": "", "editor.record.loadError.title": "", diff --git a/translations/pt.json b/translations/pt.json index ecf18380ba..58095f6e35 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -24,6 +24,10 @@ "dashboard.catalog.discussion": "", "dashboard.catalog.thesaurus": "", "dashboard.createRecord": "", + "dashboard.importRecord": "", + "dashboard.importRecord.importExternal": "", + "dashboard.importRecord.importExternalLabel": "", + "dashboard.importRecord.useModel": "", "dashboard.labels.catalog": "Catálogo", "dashboard.labels.mySpace": "Meu espaço", "dashboard.records.all": "Catálogo", @@ -251,6 +255,10 @@ "editor.record.form.temporalExtents.date": "", "editor.record.form.temporalExtents.range": "", "editor.record.form.updateFrequency.planned": "", + "editor.record.importFromExternalFile.failure.body": "", + "editor.record.importFromExternalFile.failure.title": "", + "editor.record.importFromExternalFile.success.body": "", + "editor.record.importFromExternalFile.success.title": "", "editor.record.loadError.body": "", "editor.record.loadError.closeMessage": "", "editor.record.loadError.title": "", diff --git a/translations/sk.json b/translations/sk.json index d1ce1d9d8c..a1efbf9e37 100644 --- a/translations/sk.json +++ b/translations/sk.json @@ -24,6 +24,10 @@ "dashboard.catalog.discussion": "", "dashboard.catalog.thesaurus": "", "dashboard.createRecord": "", + "dashboard.importRecord": "", + "dashboard.importRecord.importExternal": "", + "dashboard.importRecord.importExternalLabel": "", + "dashboard.importRecord.useModel": "", "dashboard.labels.catalog": "Katalóg", "dashboard.labels.mySpace": "Môj priestor", "dashboard.records.all": "Katalóg", @@ -251,6 +255,10 @@ "editor.record.form.temporalExtents.date": "", "editor.record.form.temporalExtents.range": "", "editor.record.form.updateFrequency.planned": "", + "editor.record.importFromExternalFile.failure.body": "", + "editor.record.importFromExternalFile.failure.title": "", + "editor.record.importFromExternalFile.success.body": "", + "editor.record.importFromExternalFile.success.title": "", "editor.record.loadError.body": "", "editor.record.loadError.closeMessage": "", "editor.record.loadError.title": "",