diff --git a/apps/metadata-editor/src/app/edit-record.resolver.spec.ts b/apps/metadata-editor/src/app/edit-record.resolver.spec.ts index 15d76f32a2..f8de1c1e1c 100644 --- a/apps/metadata-editor/src/app/edit-record.resolver.spec.ts +++ b/apps/metadata-editor/src/app/edit-record.resolver.spec.ts @@ -4,16 +4,18 @@ 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 { EditorService } from '@geonetwork-ui/feature/editor' 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 EditorServiceMock { - loadRecordByUuid = jest.fn(() => of(DATASET_RECORDS[0])) +class RecordsRepositoryMock { + openRecordForEdition = jest.fn(() => + of([DATASET_RECORDS[0], 'blabla', false]) + ) } const activatedRoute = { @@ -22,20 +24,23 @@ const activatedRoute = { describe('EditRecordResolver', () => { let resolver: EditRecordResolver - let editorService: EditorService + let recordsRepository: RecordsRepositoryInterface let notificationsService: NotificationsService - let record: CatalogRecord + let resolvedData: [CatalogRecord, string, boolean] beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule, TranslateModule.forRoot()], providers: [ { provide: NotificationsService, useClass: NotificationsServiceMock }, - { provide: EditorService, useClass: EditorServiceMock }, + { + provide: RecordsRepositoryInterface, + useClass: RecordsRepositoryMock, + }, ], }) resolver = TestBed.inject(EditRecordResolver) - editorService = TestBed.inject(EditorService) + recordsRepository = TestBed.inject(RecordsRepositoryInterface) notificationsService = TestBed.inject(NotificationsService) }) @@ -45,23 +50,27 @@ describe('EditRecordResolver', () => { describe('load record success', () => { beforeEach(() => { - record = undefined - resolver.resolve(activatedRoute, null).subscribe((r) => (record = r)) + resolvedData = undefined + resolver.resolve(activatedRoute).subscribe((r) => (resolvedData = r)) }) it('should load record by uuid', () => { - expect(record).toBe(DATASET_RECORDS[0]) + expect(resolvedData).toEqual([ + DATASET_RECORDS[0], + 'blabla', + false, + ]) }) }) describe('load record failure', () => { beforeEach(() => { - editorService.loadRecordByUuid = () => + recordsRepository.openRecordForEdition = () => throwError(() => new Error('oopsie')) - record = undefined - resolver.resolve(activatedRoute, null).subscribe((r) => (record = r)) + resolvedData = undefined + resolver.resolve(activatedRoute).subscribe((r) => (resolvedData = r)) }) it('should not emit anything', () => { - expect(record).toBeUndefined() + expect(resolvedData).toBeUndefined() }) it('should show error notification', () => { expect(notificationsService.showNotification).toHaveBeenCalledWith({ diff --git a/apps/metadata-editor/src/app/edit-record.resolver.ts b/apps/metadata-editor/src/app/edit-record.resolver.ts index 96cfb312db..337a1bf858 100644 --- a/apps/metadata-editor/src/app/edit-record.resolver.ts +++ b/apps/metadata-editor/src/app/edit-record.resolver.ts @@ -1,39 +1,42 @@ import { Injectable } from '@angular/core' -import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router' +import { ActivatedRouteSnapshot } from '@angular/router' import { catchError, EMPTY, Observable } from 'rxjs' -import { EditorService } from '@geonetwork-ui/feature/editor' 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 EditRecordResolver { constructor( - private editorService: EditorService, + private recordsRepository: RecordsRepositoryInterface, private notificationsService: NotificationsService, private translateService: TranslateService ) {} resolve( - route: ActivatedRouteSnapshot, - state: RouterStateSnapshot - ): Observable { - return this.editorService.loadRecordByUuid(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' - ), + route: ActivatedRouteSnapshot + ): Observable<[CatalogRecord, string, boolean]> { + return this.recordsRepository + .openRecordForEdition(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 }) - return EMPTY - }) - ) + ) } } diff --git a/apps/metadata-editor/src/app/edit/components/top-toolbar/top-toolbar.component.html b/apps/metadata-editor/src/app/edit/components/top-toolbar/top-toolbar.component.html index 365dcea5af..aab1948240 100644 --- a/apps/metadata-editor/src/app/edit/components/top-toolbar/top-toolbar.component.html +++ b/apps/metadata-editor/src/app/edit/components/top-toolbar/top-toolbar.component.html @@ -11,7 +11,33 @@ undo -
Save status
+
+ + check_circle + editor.record.saveStatus.asDraftOnly + + + check_circle + editor.record.saveStatus.recordUpToDate + + + pending + editor.record.saveStatus.draftWithChangesPending + +
help diff --git a/apps/metadata-editor/src/app/edit/components/top-toolbar/top-toolbar.component.spec.ts b/apps/metadata-editor/src/app/edit/components/top-toolbar/top-toolbar.component.spec.ts index 77c3cb5b09..5c37c3f73f 100644 --- a/apps/metadata-editor/src/app/edit/components/top-toolbar/top-toolbar.component.spec.ts +++ b/apps/metadata-editor/src/app/edit/components/top-toolbar/top-toolbar.component.spec.ts @@ -2,6 +2,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { TopToolbarComponent } from './top-toolbar.component' import { Component } from '@angular/core' import { PublishButtonComponent } from '../publish-button/publish-button.component' +import { BehaviorSubject } from 'rxjs' +import { EditorFacade } from '@geonetwork-ui/feature/editor' +import { TranslateModule } from '@ngx-translate/core' + +class EditorFacadeMock { + changedSinceSave$ = new BehaviorSubject(false) + alreadySavedOnce$ = new BehaviorSubject(false) +} @Component({ selector: 'md-editor-publish-button', @@ -13,10 +21,17 @@ class MockPublishButtonComponent {} describe('TopToolbarComponent', () => { let component: TopToolbarComponent let fixture: ComponentFixture + let editorFacade: EditorFacadeMock beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [TopToolbarComponent], + imports: [TopToolbarComponent, TranslateModule.forRoot()], + providers: [ + { + provide: EditorFacade, + useClass: EditorFacadeMock, + }, + ], }) .overrideComponent(TopToolbarComponent, { add: { @@ -30,10 +45,47 @@ describe('TopToolbarComponent', () => { fixture = TestBed.createComponent(TopToolbarComponent) component = fixture.componentInstance + editorFacade = TestBed.inject(EditorFacade) as any fixture.detectChanges() }) it('should create', () => { expect(component).toBeTruthy() }) + + describe('save status', () => { + let saveStatus: string + beforeEach(() => { + component['saveStatus$'].subscribe((status) => { + saveStatus = status + }) + }) + describe('saved and not published', () => { + beforeEach(() => { + editorFacade.alreadySavedOnce$.next(false) + editorFacade.changedSinceSave$.next(true) + }) + it('sets the correct status', () => { + expect(saveStatus).toBe('draft_only') + }) + }) + describe('saved, published and up to date', () => { + beforeEach(() => { + editorFacade.alreadySavedOnce$.next(true) + editorFacade.changedSinceSave$.next(false) + }) + it('sets the correct status', () => { + expect(saveStatus).toBe('record_up_to_date') + }) + }) + describe('saved, published, pending changes', () => { + beforeEach(() => { + editorFacade.alreadySavedOnce$.next(true) + editorFacade.changedSinceSave$.next(true) + }) + it('sets the correct status', () => { + expect(saveStatus).toBe('draft_changes_pending') + }) + }) + }) }) diff --git a/apps/metadata-editor/src/app/edit/components/top-toolbar/top-toolbar.component.ts b/apps/metadata-editor/src/app/edit/components/top-toolbar/top-toolbar.component.ts index c7717d8342..36e6cba0e3 100644 --- a/apps/metadata-editor/src/app/edit/components/top-toolbar/top-toolbar.component.ts +++ b/apps/metadata-editor/src/app/edit/components/top-toolbar/top-toolbar.component.ts @@ -3,6 +3,10 @@ import { CommonModule } from '@angular/common' import { PublishButtonComponent } from '../publish-button/publish-button.component' import { ButtonComponent } from '@geonetwork-ui/ui/inputs' import { MatIconModule } from '@angular/material/icon' +import { EditorFacade } from '@geonetwork-ui/feature/editor' +import { combineLatest, Observable } from 'rxjs' +import { map } from 'rxjs/operators' +import { TranslateModule } from '@ngx-translate/core' @Component({ selector: 'md-editor-top-toolbar', @@ -12,9 +16,35 @@ import { MatIconModule } from '@angular/material/icon' PublishButtonComponent, ButtonComponent, MatIconModule, + TranslateModule, ], templateUrl: './top-toolbar.component.html', styleUrls: ['./top-toolbar.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class TopToolbarComponent {} +export class TopToolbarComponent { + protected SaveStatus = [ + 'draft_only', // => when creating a record + 'record_up_to_date', // => when the record was just published (ie saved on the server) + 'draft_changes_pending', // => when the record was modified and not yet published + // these are not used since the draft is saved locally in a synchronous way + // TODO: use these states when the draft is saved on the server + // 'draft_saving', + // 'draft_saving_failed', + ] as const + + protected saveStatus$: Observable = + combineLatest([ + this.editorFacade.alreadySavedOnce$, + this.editorFacade.changedSinceSave$, + ]).pipe( + map(([alreadySavedOnce, changedSinceSave]) => { + if (!alreadySavedOnce) { + return 'draft_only' + } + return changedSinceSave ? 'draft_changes_pending' : 'record_up_to_date' + }) + ) + + constructor(private editorFacade: EditorFacade) {} +} diff --git a/apps/metadata-editor/src/app/edit/edit-page.component.spec.ts b/apps/metadata-editor/src/app/edit/edit-page.component.spec.ts index 7b2671b56b..3091b23bda 100644 --- a/apps/metadata-editor/src/app/edit/edit-page.component.spec.ts +++ b/apps/metadata-editor/src/app/edit/edit-page.component.spec.ts @@ -11,7 +11,7 @@ import { TranslateModule } from '@ngx-translate/core' const getRoute = () => ({ snapshot: { data: { - record: DATASET_RECORDS[0], + record: [DATASET_RECORDS[0], 'blabla', false], }, }, }) @@ -64,7 +64,11 @@ describe('EditPageComponent', () => { describe('initial state', () => { it('calls openRecord', () => { - expect(facade.openRecord).toHaveBeenCalledWith(DATASET_RECORDS[0]) + expect(facade.openRecord).toHaveBeenCalledWith( + DATASET_RECORDS[0], + 'blabla', + false + ) }) }) diff --git a/apps/metadata-editor/src/app/edit/edit-page.component.ts b/apps/metadata-editor/src/app/edit/edit-page.component.ts index 88373fc351..2e2351fcec 100644 --- a/apps/metadata-editor/src/app/edit/edit-page.component.ts +++ b/apps/metadata-editor/src/app/edit/edit-page.component.ts @@ -42,8 +42,13 @@ export class EditPageComponent implements OnInit, OnDestroy { ) {} ngOnInit(): void { - const currentRecord = this.route.snapshot.data['record'] - this.facade.openRecord(currentRecord) + const [currentRecord, currentRecordSource, currentRecordAlreadySaved] = + this.route.snapshot.data['record'] + this.facade.openRecord( + currentRecord, + currentRecordSource, + currentRecordAlreadySaved + ) this.subscription.add( this.facade.saveError$.subscribe((error) => { diff --git a/apps/webcomponents/src/app/components/base.component.ts b/apps/webcomponents/src/app/components/base.component.ts index 5c8851ab01..422cfada74 100644 --- a/apps/webcomponents/src/app/components/base.component.ts +++ b/apps/webcomponents/src/app/components/base.component.ts @@ -116,9 +116,7 @@ export class BaseComponent implements OnChanges, OnInit { uuid: string, usages: LinkUsage[] ): Promise { - const record = await firstValueFrom( - this.recordsRepository.getByUniqueIdentifier(uuid) - ) + const record = await firstValueFrom(this.recordsRepository.getRecord(uuid)) if (record?.kind !== 'dataset') { return null } diff --git a/jest.setup.ts b/jest.setup.ts index 983b1b49e6..2bf88161fb 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,4 +1,4 @@ -import { TextEncoder, TextDecoder } from 'util' +import { TextDecoder, TextEncoder } from 'util' // this is needed because jsdom does not include these as globals by default // see https://github.com/jsdom/jsdom/issues/2524 @@ -11,3 +11,26 @@ if (process.env.TEST_HIDE_CONSOLE) { console.warn = () => {} console.error = () => {} } + +// mock local storage (create a new one each time) +class LocalStorageRefStub { + store: Record = {} + mockLocalStorage = { + getItem: jest.fn((key: string): string => { + return key in this.store ? this.store[key] : null + }), + setItem: jest.fn((key: string, value: string) => { + this.store[key] = `${value}` + }), + removeItem: jest.fn((key: string) => delete this.store[key]), + clear: jest.fn(() => (this.store = {})), + } + public getLocalStorage() { + return this.mockLocalStorage + } +} +beforeEach(() => { + Object.defineProperty(window, 'localStorage', { + value: new LocalStorageRefStub().getLocalStorage(), + }) +}) 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 bbea77b453..296765df33 100644 --- a/libs/api/repository/src/lib/gn4/gn4-repository.spec.ts +++ b/libs/api/repository/src/lib/gn4/gn4-repository.spec.ts @@ -1,6 +1,9 @@ import { Gn4Repository } from './gn4-repository' -import { SearchApiService } from '@geonetwork-ui/data-access/gn4' -import { lastValueFrom, of } from 'rxjs' +import { + RecordsApiService, + SearchApiService, +} from '@geonetwork-ui/data-access/gn4' +import { firstValueFrom, lastValueFrom, of, throwError } from 'rxjs' import { ElasticsearchService } from './elasticsearch' import { TestBed } from '@angular/core/testing' import { @@ -11,8 +14,14 @@ import { Aggregations, SearchResults, } from '@geonetwork-ui/common/domain/model/search' -import { DATASET_RECORDS } from '@geonetwork-ui/common/fixtures' +import { + DATASET_RECORD_SIMPLE, + DATASET_RECORD_SIMPLE_AS_XML, + DATASET_RECORDS, +} from '@geonetwork-ui/common/fixtures' import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' +import { map } from 'rxjs/operators' +import { HttpErrorResponse } from '@angular/common/http' class Gn4MetadataMapperMock { readRecords = jest.fn((records) => @@ -56,10 +65,22 @@ class SearchApiServiceMock { }) } +class RecordsApiServiceMock { + getRecordAs = jest.fn((uuid) => + of(` + + ${uuid} + +`).pipe(map((xml) => ({ body: xml }))) + ) + insert = jest.fn(() => of({})) +} + describe('Gn4Repository', () => { let repository: Gn4Repository let gn4Helper: ElasticsearchService let gn4SearchApi: SearchApiService + let gn4RecordsApi: RecordsApiService beforeEach(() => { TestBed.configureTestingModule({ @@ -73,6 +94,10 @@ describe('Gn4Repository', () => { provide: SearchApiService, useClass: SearchApiServiceMock, }, + { + provide: RecordsApiService, + useClass: RecordsApiServiceMock, + }, { provide: Gn4Converter, useClass: Gn4MetadataMapperMock, @@ -82,6 +107,7 @@ describe('Gn4Repository', () => { repository = TestBed.inject(Gn4Repository) gn4Helper = TestBed.inject(ElasticsearchService) gn4SearchApi = TestBed.inject(SearchApiService) + gn4RecordsApi = TestBed.inject(RecordsApiService) }) it('creates', () => { expect(repository).toBeTruthy() @@ -148,12 +174,10 @@ describe('Gn4Repository', () => { expect(count).toStrictEqual(1234) }) }) - describe('getByUniqueIdentifier', () => { + describe('getRecord', () => { let record: CatalogRecord beforeEach(async () => { - record = await lastValueFrom( - repository.getByUniqueIdentifier('1234-5678') - ) + record = await lastValueFrom(repository.getRecord('1234-5678')) }) it('builds a payload with the specified uuid', () => { expect(gn4Helper.getMetadataByIdPayload).toHaveBeenCalledWith('1234-5678') @@ -169,9 +193,7 @@ describe('Gn4Repository', () => { hits: [], }, }) - record = await lastValueFrom( - repository.getByUniqueIdentifier('1234-5678') - ) + record = await lastValueFrom(repository.getRecord('1234-5678')) }) it('returns null', () => { expect(record).toBe(null) @@ -234,4 +256,182 @@ describe('Gn4Repository', () => { expect(results.records).toStrictEqual(DATASET_RECORDS) }) }) + describe('openRecordForEdition', () => { + let record: CatalogRecord + let recordSource: string + let savedOnce: boolean + + describe('if the record is present in the backend', () => { + beforeEach(async () => { + ;[record, recordSource, savedOnce] = await lastValueFrom( + repository.openRecordForEdition('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', () => { + expect(record).toMatchObject({ uniqueIdentifier: '1234-5678' }) + }) + it('loads the source & tells the record is present in the backend', () => { + expect(recordSource).toMatch(//) + expect(savedOnce).toBe(true) + }) + }) + describe('if the record is present as draft but not in the backend', () => { + let recordSource: string + beforeEach(async () => { + recordSource = await firstValueFrom( + repository.saveRecordAsDraft({ + ...DATASET_RECORD_SIMPLE, + uniqueIdentifier: '1234-5678', + }) + ) + ;(gn4RecordsApi.getRecordAs as jest.Mock).mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ status: 404 })) + ) + ;[record, recordSource, savedOnce] = await lastValueFrom( + repository.openRecordForEdition('1234-5678') + ) + }) + 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 + describe('with reference', () => { + beforeEach(async () => { + recordSource = await lastValueFrom( + repository.saveRecord( + DATASET_RECORD_SIMPLE, + DATASET_RECORD_SIMPLE_AS_XML + ) + ) + }) + it('uses a converter that matches the reference', () => { + const recordXml = (gn4RecordsApi.insert as jest.Mock).mock.calls[0][14] + expect(recordXml).toMatch(` + + + my-dataset-001`) + }) + it('calls the API to insert the record as XML', () => { + expect(gn4RecordsApi.insert).toHaveBeenCalledWith( + expect.anything(), + undefined, + undefined, + undefined, + expect.anything(), + undefined, + expect.anything(), + undefined, + undefined, + undefined, + expect.anything(), + undefined, + undefined, + undefined, + expect.stringMatching(` + + + my-dataset-001`) + ) + }) + it('returns the record as serialized', () => { + expect(recordSource).toMatch(/ { + beforeEach(async () => { + await lastValueFrom(repository.saveRecord(DATASET_RECORDS[0])) + }) + it('uses the ISO19139 converter by default', () => { + const recordXml = (gn4RecordsApi.insert as jest.Mock).mock.calls[0][14] + expect(recordXml).toMatch(` + + ${DATASET_RECORD_SIMPLE.uniqueIdentifier} + `) + }) + }) + }) + describe('record draft', () => { + beforeEach(async () => { + // save a record, then a draft, then open the record again + await lastValueFrom( + repository.saveRecord( + DATASET_RECORD_SIMPLE, + DATASET_RECORD_SIMPLE_AS_XML + ) + ) + await lastValueFrom( + repository.saveRecordAsDraft( + { + ...DATASET_RECORD_SIMPLE, + title: 'The title has been modified', + }, + DATASET_RECORD_SIMPLE_AS_XML + ) + ) + }) + describe('#openRecordForEdition', () => { + it('loads the draft instead of the original one', async () => { + const [record] = await lastValueFrom( + repository.openRecordForEdition( + DATASET_RECORD_SIMPLE.uniqueIdentifier + ) + ) + expect(record).toStrictEqual({ + ...DATASET_RECORD_SIMPLE, + title: 'The title has been modified', + }) + }) + }) + describe('#clearRecordDraft', () => { + beforeEach(() => { + repository.clearRecordDraft(DATASET_RECORD_SIMPLE.uniqueIdentifier) + }) + it('removes the record draft', async () => { + const [record] = await lastValueFrom( + repository.openRecordForEdition( + DATASET_RECORD_SIMPLE.uniqueIdentifier + ) + ) + expect(record?.title).not.toBe('The title has been modified') + const hasDraft = repository.recordHasDraft( + DATASET_RECORD_SIMPLE.uniqueIdentifier + ) + + expect(hasDraft).toBe(false) + }) + }) + describe('#recordHasDraft', () => { + it('returns true when there is a draft', () => { + const hasDraft = repository.recordHasDraft( + DATASET_RECORD_SIMPLE.uniqueIdentifier + ) + expect(hasDraft).toBe(true) + }) + it('returns false otherwise', () => { + const hasDraft = repository.recordHasDraft('blargz') + expect(hasDraft).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 a31a61aa81..387227b6c8 100644 --- a/libs/api/repository/src/lib/gn4/gn4-repository.ts +++ b/libs/api/repository/src/lib/gn4/gn4-repository.ts @@ -1,7 +1,17 @@ import { Injectable } from '@angular/core' -import { SearchApiService } from '@geonetwork-ui/data-access/gn4' +import { + RecordsApiService, + SearchApiService, +} from '@geonetwork-ui/data-access/gn4' import { ElasticsearchService } from './elasticsearch' -import { Observable, of, switchMap } from 'rxjs' +import { + combineLatest, + from, + Observable, + of, + switchMap, + throwError, +} from 'rxjs' import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface' import { SearchParams, @@ -12,19 +22,23 @@ import { AggregationsParams, FieldFilters, } from '@geonetwork-ui/common/domain/model/search' -import { map } from 'rxjs/operators' +import { catchError, map, tap } from 'rxjs/operators' import { + 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' @Injectable() export class Gn4Repository implements RecordsRepositoryInterface { constructor( private gn4SearchApi: SearchApiService, private gn4SearchHelper: ElasticsearchService, - private gn4Mapper: Gn4Converter + private gn4Mapper: Gn4Converter, + private gn4RecordsApi: RecordsApiService ) {} search({ @@ -84,9 +98,7 @@ export class Gn4Repository implements RecordsRepositoryInterface { .pipe(map((results: Gn4SearchResults) => results.hits.total?.value || 0)) } - getByUniqueIdentifier( - uniqueIdentifier: string - ): Observable { + getRecord(uniqueIdentifier: string): Observable { return this.gn4SearchApi .search( 'bucket', @@ -165,4 +177,130 @@ 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) + return combineLatest([draft$, recordAsXml$]).pipe( + switchMap(([draft, recordAsXml]) => { + const xml = draft ?? recordAsXml + const isSavedAlready = recordAsXml !== null + return findConverterForDocument(xml) + .readRecord(xml) + .then( + (record) => + [record, xml, isSavedAlready] as [CatalogRecord, string, boolean] + ) + }) + ) + } + + 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 + ): Observable { + return this.serializeRecordToXml(record, referenceRecordSource).pipe( + switchMap((recordXml) => + this.gn4RecordsApi + .insert( + 'METADATA', + undefined, + undefined, + undefined, + true, + undefined, + 'OVERWRITE', + undefined, + undefined, + undefined, + '_none_', + undefined, + undefined, + undefined, + recordXml + ) + .pipe(map(() => recordXml)) + ), + tap(() => { + // if saving was successful, the associated draft can be discarded + window.localStorage.removeItem( + this.getLocalStorageKeyForRecord(record.uniqueIdentifier) + ) + }) + ) + } + + saveRecordAsDraft( + record: CatalogRecord, + referenceRecordSource?: string + ): Observable { + return this.serializeRecordToXml(record, referenceRecordSource).pipe( + tap((recordXml) => + window.localStorage.setItem( + this.getLocalStorageKeyForRecord(record.uniqueIdentifier), + recordXml + ) + ) + ) + } + + clearRecordDraft(uniqueIdentifier: string): void { + window.localStorage.removeItem( + this.getLocalStorageKeyForRecord(uniqueIdentifier) + ) + } + + recordHasDraft(uniqueIdentifier: string): boolean { + return ( + window.localStorage.getItem( + this.getLocalStorageKeyForRecord(uniqueIdentifier) + ) !== null + ) + } } 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 063873bc99..f7a9fb3026 100644 --- a/libs/common/domain/src/lib/repository/records-repository.interface.ts +++ b/libs/common/domain/src/lib/repository/records-repository.interface.ts @@ -11,12 +11,45 @@ import { CatalogRecord } from '../model/record' export abstract class RecordsRepositoryInterface { abstract search(params: SearchParams): Observable abstract getMatchesCount(filters: FieldFilters): Observable - abstract getByUniqueIdentifier( - uniqueIdentifier: string - ): Observable + abstract getRecord(uniqueIdentifier: string): Observable abstract aggregate(params: AggregationsParams): Observable abstract getSimilarRecords( similarTo: CatalogRecord ): Observable abstract fuzzySearch(query: string): Observable + + /** + * This emits once: + * - record object; if a draft exists, this will return it + * - serialized representation of the record as text + * - boolean indicating if the record has been saved at least once in a final version (i.e. not only as draft) + * @param uniqueIdentifier + * @returns Observable<[CatalogRecord, string, boolean] | null> + */ + abstract openRecordForEdition( + uniqueIdentifier: string + ): Observable<[CatalogRecord, string, boolean] | null> + + /** + * @param record + * @param referenceRecordSource + * @returns Observable Returns the source of the record as it was serialized when saved + */ + abstract saveRecord( + record: CatalogRecord, + referenceRecordSource?: string + ): Observable + + /** + * @param record + * @param referenceRecordSource + * @returns Observable Returns the source of the record as it was serialized when saved + */ + abstract saveRecordAsDraft( + record: CatalogRecord, + referenceRecordSource?: string + ): Observable + + abstract clearRecordDraft(uniqueIdentifier: string): void + abstract recordHasDraft(uniqueIdentifier: string): boolean } diff --git a/libs/common/fixtures/src/lib/records.fixtures.ts b/libs/common/fixtures/src/lib/records.fixtures.ts index c193335ee6..3e7177423a 100644 --- a/libs/common/fixtures/src/lib/records.fixtures.ts +++ b/libs/common/fixtures/src/lib/records.fixtures.ts @@ -252,3 +252,206 @@ Ce lot de données produit en 2019, a été numérisé à partir du PCI Vecteur languages: ['fr', 'de'], }, ]) + +export const DATASET_RECORD_SIMPLE: DatasetRecord = { + uniqueIdentifier: 'my-dataset-001', + kind: 'dataset', + languages: [], + recordUpdated: new Date('2022-02-01T14:12:00.000Z'), + resourceCreated: new Date('2022-09-01T12:18:19.000Z'), + resourceUpdated: new Date('2022-12-04T14:12:00.000Z'), + status: 'ongoing', + title: 'A very interesting dataset (un jeu de données très intéressant)', + abstract: `This dataset has been established for testing purposes.`, + ownerOrganization: { name: 'MyOrganization' }, + contacts: [ + { + email: 'bob@org.net', + position: 'developer', + organization: { name: 'MyOrganization' }, + role: 'point_of_contact', + firstName: 'Bob', + lastName: 'TheGreat', + }, + ], + contactsForResource: [], + keywords: [], + topics: ['testData'], + licenses: [], + legalConstraints: [], + securityConstraints: [], + otherConstraints: [], + lineage: 'This record was edited manually to test the conversion processes', + spatialRepresentation: 'grid', + overviews: [], + spatialExtents: [], + temporalExtents: [], + distributions: [ + { + type: 'download', + url: new URL('http://my-org.net/download/1.zip'), + name: 'Direct download', + description: 'Dataset downloaded as a shapefile', + mimeType: 'x-gis/x-shapefile', + }, + ], + updateFrequency: { per: 'month', updatedTimes: 3 }, +} + +export const DATASET_RECORD_SIMPLE_AS_XML = ` + + + + + my-dataset-001 + + + + + + + dataset + + + + + + + pointOfContact + + + + + MyOrganization + + + + + + + bob@org.net + + + + + + + + + Bob TheGreat + + + developer + + + + + + + + + + + 2022-02-01T15:12:00 + + + revision + + + + + + + + + A very interesting dataset (un jeu de données très intéressant) + + + + + 2022-09-01T14:18:19 + + + creation + + + + + + + 2022-12-04T15:12:00 + + + revision + + + + + + + This dataset has been established for testing purposes. + + + testData + + + onGoing + + + + + P0Y0M10D + + + + + grid + + + + + + + + + + + x-gis/x-shapefile + + + + + + + + + + + http://my-org.net/download/1.zip + + + Dataset downloaded as a shapefile + + + Direct download + + + WWW:DOWNLOAD + + + + + + + + + + + + + + This record was edited manually to test the conversion processes + + + +` diff --git a/libs/data-access/gn4/src/openapi/api/records.api.service.ts b/libs/data-access/gn4/src/openapi/api/records.api.service.ts index 04cb7f290c..6884ce91a7 100644 --- a/libs/data-access/gn4/src/openapi/api/records.api.service.ts +++ b/libs/data-access/gn4/src/openapi/api/records.api.service.ts @@ -4586,8 +4586,8 @@ export class RecordsApiService { accept?: string, observe?: 'body', reportProgress?: boolean, - options?: { httpHeaderAccept?: 'application/json' | 'application/xml' } - ): Observable + options?: { httpHeaderAccept?: 'application/xml' | 'application/json' } + ): Observable public getRecordAs( metadataUuid: string, addSchemaLocation?: boolean, @@ -4598,8 +4598,8 @@ export class RecordsApiService { accept?: string, observe?: 'response', reportProgress?: boolean, - options?: { httpHeaderAccept?: 'application/json' | 'application/xml' } - ): Observable> + options?: { httpHeaderAccept?: 'application/xml' | 'application/json' } + ): Observable> public getRecordAs( metadataUuid: string, addSchemaLocation?: boolean, @@ -4610,8 +4610,8 @@ export class RecordsApiService { accept?: string, observe?: 'events', reportProgress?: boolean, - options?: { httpHeaderAccept?: 'application/json' | 'application/xml' } - ): Observable> + options?: { httpHeaderAccept?: 'application/xml' | 'application/json' } + ): Observable> public getRecordAs( metadataUuid: string, addSchemaLocation?: boolean, @@ -4622,7 +4622,7 @@ export class RecordsApiService { accept?: string, observe: any = 'body', reportProgress: boolean = false, - options?: { httpHeaderAccept?: 'application/json' | 'application/xml' } + options?: { httpHeaderAccept?: 'application/xml' | 'application/json' } ): Observable { if (metadataUuid === null || metadataUuid === undefined) { throw new Error( @@ -4677,8 +4677,8 @@ export class RecordsApiService { if (httpHeaderAcceptSelected === undefined) { // to determine the Accept header const httpHeaderAccepts: string[] = [ - 'application/json', 'application/xml', + 'application/json', ] httpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts) @@ -4695,7 +4695,7 @@ export class RecordsApiService { responseType_ = 'text' } - return this.httpClient.get( + return this.httpClient.get( `${this.configuration.basePath}/records/${encodeURIComponent( String(metadataUuid) )}/formatters/xml`, diff --git a/libs/data-access/gn4/src/spec.yaml b/libs/data-access/gn4/src/spec.yaml index 93ce3a5b5c..ee451ac7a1 100644 --- a/libs/data-access/gn4/src/spec.yaml +++ b/libs/data-access/gn4/src/spec.yaml @@ -11114,16 +11114,15 @@ paths: default: description: default response content: - application/json: {} + application/xml: + schema: + type: string "200": description: Return the record. content: application/xml: schema: - type: object - application/json: - schema: - type: object + type: string "403": description: Operation not allowed. User needs to be able to view the resource. content: diff --git a/libs/feature/editor/src/lib/+state/editor.actions.ts b/libs/feature/editor/src/lib/+state/editor.actions.ts index c9886a46e2..50d0b4f139 100644 --- a/libs/feature/editor/src/lib/+state/editor.actions.ts +++ b/libs/feature/editor/src/lib/+state/editor.actions.ts @@ -4,7 +4,11 @@ import { SaveRecordError } from './editor.models' export const openRecord = createAction( '[Editor] Open record', - props<{ record: CatalogRecord }>() + props<{ + record: CatalogRecord + alreadySavedOnce: boolean + recordSource?: string | null + }>() ) export const updateRecordField = createAction( diff --git a/libs/feature/editor/src/lib/+state/editor.effects.spec.ts b/libs/feature/editor/src/lib/+state/editor.effects.spec.ts index f984d3cfb3..631ad05807 100644 --- a/libs/feature/editor/src/lib/+state/editor.effects.spec.ts +++ b/libs/feature/editor/src/lib/+state/editor.effects.spec.ts @@ -2,16 +2,20 @@ import { TestBed } from '@angular/core/testing' import { provideMockActions } from '@ngrx/effects/testing' import { Action } from '@ngrx/store' import { provideMockStore } from '@ngrx/store/testing' -import { hot } from 'jasmine-marbles' +import { getTestScheduler, hot } from 'jasmine-marbles' import { Observable, of, throwError } from 'rxjs' import * as EditorActions from './editor.actions' import { EditorEffects } from './editor.effects' import { DATASET_RECORDS } from '@geonetwork-ui/common/fixtures' import { EditorService } from '../services/editor.service' +import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface' class EditorServiceMock { - loadRecordByUuid = jest.fn(() => of(DATASET_RECORDS[0])) - saveRecord = jest.fn((record) => of(record)) + saveRecord = jest.fn((record) => of([record, 'blabla'])) + saveRecordAsDraft = jest.fn(() => of('blabla')) +} +class RecordsRepositoryMock { + recordHasDraft = jest.fn(() => true) } describe('EditorEffects', () => { @@ -41,6 +45,10 @@ describe('EditorEffects', () => { provide: EditorService, useClass: EditorServiceMock, }, + { + provide: RecordsRepositoryInterface, + useClass: RecordsRepositoryMock, + }, ], }) @@ -56,7 +64,11 @@ describe('EditorEffects', () => { }) const expected = hot('-(ab)|', { a: EditorActions.saveRecordSuccess(), - b: EditorActions.openRecord({ record: DATASET_RECORDS[0] }), + b: EditorActions.openRecord({ + record: DATASET_RECORDS[0], + alreadySavedOnce: true, + recordSource: 'blabla', + }), }) expect(effects.saveRecord$).toBeObservable(expected) }) @@ -94,4 +106,69 @@ describe('EditorEffects', () => { expect(effects.markAsChanged$).toBeObservable(expected) }) }) + + describe('saveRecordDraft$', () => { + it('does not dispatch any action', () => { + actions = hot('-a-', { + a: EditorActions.updateRecordField({ + field: 'title', + value: 'Hello world', + }), + }) + expect(effects.saveRecordDraft$).toBeObservable(hot('---')) + expect(service.saveRecordAsDraft).not.toHaveBeenCalled() + }) + it('calls editorService.saveRecordAsDraft after 1000ms', () => { + getTestScheduler().run(() => { + actions = hot('a-a 1050ms -', { + a: EditorActions.updateRecordField({ + field: 'title', + value: 'Hello world', + }), + }) + expect(effects.saveRecordDraft$).toBeObservable( + hot('--- 999ms b', { + b: 'blabla', // this is emitted by the observable but not dispatched as an action + }) + ) + expect(service.saveRecordAsDraft).toHaveBeenCalledWith( + DATASET_RECORDS[0] + ) + }) + }) + }) + + describe('checkHasChangesOnOpen$', () => { + describe('if the record has a draft', () => { + it('dispatch markRecordAsChanged', () => { + actions = hot('-a-|', { + a: EditorActions.openRecord({ + record: DATASET_RECORDS[0], + alreadySavedOnce: true, + }), + }) + const expected = hot('-a-|', { + a: EditorActions.markRecordAsChanged(), + }) + expect(effects.checkHasChangesOnOpen$).toBeObservable(expected) + }) + }) + describe('if the record has no draft', () => { + beforeEach(() => { + ;( + TestBed.inject(RecordsRepositoryInterface).recordHasDraft as jest.Mock + ).mockImplementationOnce(() => false) + }) + it('dispatches nothing', () => { + actions = hot('-a-|', { + a: EditorActions.openRecord({ + record: DATASET_RECORDS[0], + alreadySavedOnce: true, + }), + }) + const expected = hot('---|') + expect(effects.checkHasChangesOnOpen$).toBeObservable(expected) + }) + }) + }) }) diff --git a/libs/feature/editor/src/lib/+state/editor.effects.ts b/libs/feature/editor/src/lib/+state/editor.effects.ts index 6ad336761f..e435d0d335 100644 --- a/libs/feature/editor/src/lib/+state/editor.effects.ts +++ b/libs/feature/editor/src/lib/+state/editor.effects.ts @@ -1,16 +1,18 @@ import { inject, Injectable } from '@angular/core' import { Actions, createEffect, ofType } from '@ngrx/effects' -import { of, withLatestFrom } from 'rxjs' +import { debounceTime, filter, of, withLatestFrom } from 'rxjs' import { catchError, map, switchMap } from 'rxjs/operators' import * as EditorActions from './editor.actions' import { EditorService } from '../services/editor.service' import { Store } from '@ngrx/store' import { selectRecord, selectRecordFieldsConfig } from './editor.selectors' +import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface' @Injectable() export class EditorEffects { private actions$ = inject(Actions) private editorService = inject(EditorService) + private recordsRepository = inject(RecordsRepositoryInterface) private store = inject(Store) saveRecord$ = createEffect(() => @@ -22,10 +24,14 @@ export class EditorEffects { ), switchMap(([, record, fieldsConfig]) => this.editorService.saveRecord(record, fieldsConfig).pipe( - switchMap((newRecord) => + switchMap(([record, recordSource]) => of( EditorActions.saveRecordSuccess(), - EditorActions.openRecord({ record: newRecord }) + EditorActions.openRecord({ + record, + alreadySavedOnce: true, + recordSource, + }) ) ), catchError((error) => @@ -46,4 +52,26 @@ export class EditorEffects { map(() => EditorActions.markRecordAsChanged()) ) ) + + saveRecordDraft$ = createEffect( + () => + this.actions$.pipe( + ofType(EditorActions.updateRecordField), + debounceTime(1000), + withLatestFrom(this.store.select(selectRecord)), + switchMap(([, record]) => this.editorService.saveRecordAsDraft(record)) + ), + { dispatch: false } + ) + + checkHasChangesOnOpen$ = createEffect(() => + this.actions$.pipe( + ofType(EditorActions.openRecord), + map(({ record }) => + this.recordsRepository.recordHasDraft(record.uniqueIdentifier) + ), + filter((hasDraft) => hasDraft), + map(() => EditorActions.markRecordAsChanged()) + ) + ) } diff --git a/libs/feature/editor/src/lib/+state/editor.facade.ts b/libs/feature/editor/src/lib/+state/editor.facade.ts index c5ad803ef3..a20ab274a6 100644 --- a/libs/feature/editor/src/lib/+state/editor.facade.ts +++ b/libs/feature/editor/src/lib/+state/editor.facade.ts @@ -3,7 +3,7 @@ import { select, Store } from '@ngrx/store' import * as EditorActions from './editor.actions' import * as EditorSelectors from './editor.selectors' import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' -import { filter, Observable } from 'rxjs' +import { filter } from 'rxjs' import { Actions, ofType } from '@ngrx/effects' @Injectable() @@ -12,6 +12,10 @@ export class EditorFacade { private actions$ = inject(Actions) record$ = this.store.pipe(select(EditorSelectors.selectRecord)) + recordSource$ = this.store.pipe(select(EditorSelectors.selectRecordSource)) + alreadySavedOnce$ = this.store.pipe( + select(EditorSelectors.selectRecordAlreadySavedOnce) + ) saving$ = this.store.pipe(select(EditorSelectors.selectRecordSaving)) saveError$ = this.store.pipe( select(EditorSelectors.selectRecordSaveError), @@ -23,8 +27,14 @@ export class EditorFacade { ) recordFields$ = this.store.pipe(select(EditorSelectors.selectRecordFields)) - openRecord(record: CatalogRecord) { - this.store.dispatch(EditorActions.openRecord({ record })) + openRecord( + record: CatalogRecord, + recordSource: string, + alreadySavedOnce: boolean + ) { + this.store.dispatch( + EditorActions.openRecord({ record, recordSource, alreadySavedOnce }) + ) } saveRecord() { diff --git a/libs/feature/editor/src/lib/+state/editor.reducer.spec.ts b/libs/feature/editor/src/lib/+state/editor.reducer.spec.ts index 875d025409..59b175e4b6 100644 --- a/libs/feature/editor/src/lib/+state/editor.reducer.spec.ts +++ b/libs/feature/editor/src/lib/+state/editor.reducer.spec.ts @@ -1,25 +1,54 @@ import { Action } from '@ngrx/store' import * as EditorActions from './editor.actions' import { + editorReducer, EditorState, initialEditorState, - editorReducer, } from './editor.reducer' import { DATASET_RECORDS } from '@geonetwork-ui/common/fixtures' describe('Editor Reducer', () => { describe('valid Editor actions', () => { - it('openRecord', () => { + it('openRecord (with source)', () => { + const action = EditorActions.openRecord({ + record: DATASET_RECORDS[0], + recordSource: 'blabla', + alreadySavedOnce: false, + }) + const result: EditorState = editorReducer( + { + ...initialEditorState, + changedSinceSave: true, + recordSource: 'abcd', + alreadySavedOnce: true, + }, + action + ) + + expect(result.record).toBe(DATASET_RECORDS[0]) + expect(result.changedSinceSave).toBe(false) + expect(result.recordSource).toBe('blabla') + expect(result.alreadySavedOnce).toBe(false) + }) + it('openRecord (without source)', () => { const action = EditorActions.openRecord({ record: DATASET_RECORDS[0], + alreadySavedOnce: true, }) const result: EditorState = editorReducer( - { ...initialEditorState, changedSinceSave: true }, + { + ...initialEditorState, + changedSinceSave: true, + recordSource: 'blabla', + alreadySavedOnce: false, + }, action ) expect(result.record).toBe(DATASET_RECORDS[0]) expect(result.changedSinceSave).toBe(false) + expect(result.recordSource).toBe(null) + expect(result.alreadySavedOnce).toBe(true) }) it('saveRecord action', () => { const action = EditorActions.saveRecord() diff --git a/libs/feature/editor/src/lib/+state/editor.reducer.ts b/libs/feature/editor/src/lib/+state/editor.reducer.ts index 7d207264c0..425e3d0c41 100644 --- a/libs/feature/editor/src/lib/+state/editor.reducer.ts +++ b/libs/feature/editor/src/lib/+state/editor.reducer.ts @@ -7,8 +7,18 @@ import { DEFAULT_FIELDS } from '../fields.config' export const EDITOR_FEATURE_KEY = 'editor' +/** + * @property record The record being edited + * @property recordSource Original representation of the record as text, used as a reference; null means the record hasn't be serialized yet + * @property saving + * @property saveError + * @property changedSinceSave + * @property fieldsConfig Configuration for the fields in the editor + */ export interface EditorState { record: CatalogRecord | null + recordSource: string | null + alreadySavedOnce: boolean saving: boolean saveError: SaveRecordError | null changedSinceSave: boolean @@ -21,6 +31,8 @@ export interface EditorPartialState { export const initialEditorState: EditorState = { record: null, + recordSource: null, + alreadySavedOnce: false, saving: false, saveError: null, changedSinceSave: false, @@ -29,11 +41,16 @@ export const initialEditorState: EditorState = { const reducer = createReducer( initialEditorState, - on(EditorActions.openRecord, (state, { record }) => ({ - ...state, - changedSinceSave: false, - record, - })), + on( + EditorActions.openRecord, + (state, { record, recordSource, alreadySavedOnce }) => ({ + ...state, + changedSinceSave: false, + recordSource: recordSource ?? null, + alreadySavedOnce, + record, + }) + ), on(EditorActions.saveRecord, (state) => ({ ...state, saving: true, diff --git a/libs/feature/editor/src/lib/+state/editor.selectors.spec.ts b/libs/feature/editor/src/lib/+state/editor.selectors.spec.ts index 847ac23f37..dbcbedf3f2 100644 --- a/libs/feature/editor/src/lib/+state/editor.selectors.spec.ts +++ b/libs/feature/editor/src/lib/+state/editor.selectors.spec.ts @@ -11,6 +11,7 @@ describe('Editor Selectors', () => { editor: { ...initialEditorState, record: DATASET_RECORDS[0], + recordSource: 'blabla', saveError: 'something went wrong', saving: false, changedSinceSave: true, @@ -24,6 +25,11 @@ describe('Editor Selectors', () => { expect(result).toBe(DATASET_RECORDS[0]) }) + it('selectRecordSource() should return the source of the current record', () => { + const result = EditorSelectors.selectRecordSource(state) + expect(result).toBe('blabla') + }) + it('selectRecordSaving() should return the current "saving" state', () => { const result = EditorSelectors.selectRecordSaving(state) expect(result).toBe(false) @@ -39,6 +45,11 @@ describe('Editor Selectors', () => { expect(result).toBe(true) }) + it('selectRecordAlreadySavedOnce() should return the current "alreadySavedOnce" state', () => { + const result = EditorSelectors.selectRecordAlreadySavedOnce(state) + expect(result).toBe(false) + }) + it('selectRecordFieldsConfig() should return the current "fieldsConfig" state', () => { const result = EditorSelectors.selectRecordFieldsConfig(state) expect(result).toEqual(DEFAULT_FIELDS) diff --git a/libs/feature/editor/src/lib/+state/editor.selectors.ts b/libs/feature/editor/src/lib/+state/editor.selectors.ts index 2493207f4b..2af551872c 100644 --- a/libs/feature/editor/src/lib/+state/editor.selectors.ts +++ b/libs/feature/editor/src/lib/+state/editor.selectors.ts @@ -9,6 +9,11 @@ export const selectRecord = createSelector( (state: EditorState) => state.record ) +export const selectRecordSource = createSelector( + selectEditorState, + (state: EditorState) => state.recordSource +) + export const selectRecordSaving = createSelector( selectEditorState, (state: EditorState) => state.saving @@ -24,6 +29,11 @@ export const selectRecordChangedSinceSave = createSelector( (state: EditorState) => state.changedSinceSave ) +export const selectRecordAlreadySavedOnce = createSelector( + selectEditorState, + (state: EditorState) => state.alreadySavedOnce +) + export const selectRecordFieldsConfig = createSelector( selectEditorState, (state: EditorState) => state.fieldsConfig diff --git a/libs/feature/editor/src/lib/services/editor.service.spec.ts b/libs/feature/editor/src/lib/services/editor.service.spec.ts index 7ac6c6c2cc..856b0bc6ef 100644 --- a/libs/feature/editor/src/lib/services/editor.service.spec.ts +++ b/libs/feature/editor/src/lib/services/editor.service.spec.ts @@ -7,20 +7,35 @@ import { import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' import { DEFAULT_FIELDS } from '../fields.config' import { DATASET_RECORDS } from '@geonetwork-ui/common/fixtures' +import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface' +import { of } from 'rxjs' const SAMPLE_RECORD: CatalogRecord = DATASET_RECORDS[0] +class RecordsRepositoryMock { + openRecordForEdition = jest.fn(() => of(SAMPLE_RECORD)) + saveRecord = jest.fn(() => of('blabla')) + saveRecordAsDraft = jest.fn(() => of('blabla')) +} + describe('EditorService', () => { let service: EditorService let http: HttpTestingController + let repository: RecordsRepositoryInterface beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], - providers: [], + providers: [ + { + provide: RecordsRepositoryInterface, + useClass: RecordsRepositoryMock, + }, + ], }) service = TestBed.inject(EditorService) http = TestBed.inject(HttpTestingController) + repository = TestBed.inject(RecordsRepositoryInterface) }) afterEach(() => { @@ -31,49 +46,31 @@ describe('EditorService', () => { expect(service).toBeTruthy() }) - describe('loadRecordByUuid', () => { - let record: CatalogRecord + describe('saveRecord', () => { + let savedRecord: CatalogRecord beforeEach(() => { - service.loadRecordByUuid('1234-5678').subscribe((v) => (record = v)) - http.expectOne( - (req) => req.url.indexOf('/records/1234-5678/formatters/xml') > -1 - ).flush(` - - - 1234-5678 - -`) + savedRecord = null + service + .saveRecord(SAMPLE_RECORD, DEFAULT_FIELDS) + .subscribe((v) => (savedRecord = v)) }) - it('parses the XML record into a native object', () => { - expect(record).toMatchObject({ uniqueIdentifier: '1234-5678' }) + it('calls recordUpdated after applying field processes', () => { + const expected = { + ...SAMPLE_RECORD, + recordUpdated: expect.any(Date), + } + expect(repository.saveRecord).toHaveBeenCalledWith(expected) + expect(savedRecord).toEqual([expected, 'blabla']) + expect(savedRecord.recordUpdated).not.toEqual(SAMPLE_RECORD.recordUpdated) }) }) - describe('saveRecord', () => { - describe('after a record was set as current', () => { - let savedRecord: CatalogRecord - beforeEach(() => { - service - .saveRecord(SAMPLE_RECORD, DEFAULT_FIELDS) - .subscribe((v) => (savedRecord = v)) - }) - it('sends a record as XML to the API after applying field processes', () => { - const match = http.expectOne( - (req) => req.method === 'PUT' && req.url.indexOf('/records') > -1 - ) - match.flush('ok') - expect(match.request.body).toContain(` - - ${SAMPLE_RECORD.uniqueIdentifier} - `) - expect(savedRecord).toEqual({ - ...SAMPLE_RECORD, - recordUpdated: expect.any(Date), - }) - expect(savedRecord.recordUpdated).not.toEqual( - SAMPLE_RECORD.recordUpdated - ) - }) + describe('saveRecordAsDraft', () => { + beforeEach(() => { + service.saveRecordAsDraft(SAMPLE_RECORD).subscribe() + }) + it('calls saveRecordAsDraft', () => { + expect(repository.saveRecordAsDraft).toHaveBeenCalledWith(SAMPLE_RECORD) }) }) }) diff --git a/libs/feature/editor/src/lib/services/editor.service.ts b/libs/feature/editor/src/lib/services/editor.service.ts index f3f57481c5..4bf00ed25b 100644 --- a/libs/feature/editor/src/lib/services/editor.service.ts +++ b/libs/feature/editor/src/lib/services/editor.service.ts @@ -1,50 +1,22 @@ -import { Inject, Injectable, Optional } from '@angular/core' -import { - findConverterForDocument, - Iso19139Converter, -} from '@geonetwork-ui/api/metadata-converter' -import { Configuration } from '@geonetwork-ui/data-access/gn4' -import { from, Observable } from 'rxjs' -import { map, switchMap } from 'rxjs/operators' -import { HttpClient } from '@angular/common/http' +import { Injectable } from '@angular/core' +import { Observable } from 'rxjs' +import { map } from 'rxjs/operators' import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' import { EditorFieldsConfig } from '../models/fields.model' import { evaluate } from '../expressions' +import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface' @Injectable({ providedIn: 'root', }) export class EditorService { - private apiUrl = `${this.apiConfiguration?.basePath || '/geonetwork/srv/api'}` + constructor(private recordsRepository: RecordsRepositoryInterface) {} - constructor( - private http: HttpClient, - @Optional() - @Inject(Configuration) - private apiConfiguration: Configuration - ) {} - - // TODO: use the catalog repository instead - loadRecordByUuid(uuid: string): Observable { - return this.http - .get(`${this.apiUrl}/records/${uuid}/formatters/xml`, { - responseType: 'text', - headers: { - Accept: 'application/xml', - }, - }) - .pipe( - switchMap((response) => - findConverterForDocument(response).readRecord(response.toString()) - ) - ) - } - - // returns the record as it was when saved + // returns the record as it was when saved, alongside its source saveRecord( record: CatalogRecord, fieldsConfig: EditorFieldsConfig - ): Observable { + ): Observable<[CatalogRecord, string]> { const savedRecord = { ...record } // run onSave processes @@ -57,23 +29,16 @@ export class EditorService { }) } } + return this.recordsRepository + .saveRecord(savedRecord) + .pipe(map((recordSource) => [savedRecord, recordSource])) + } - // TODO: use the catalog repository instead - // TODO: use converter based on the format of the record before change - return from(new Iso19139Converter().writeRecord(savedRecord)).pipe( - switchMap((recordXml) => - this.http.put( - `${this.apiUrl}/records?metadataType=METADATA&uuidProcessing=OVERWRITE&transformWith=_none_&publishToAll=on`, - recordXml, - { - headers: { - 'Content-Type': 'application/xml', - }, - withCredentials: true, - } - ) - ), - map(() => savedRecord) - ) + // emits and completes once saving is done + // note: onSave processes are not run for drafts + saveRecordAsDraft(record: CatalogRecord): Observable { + return this.recordsRepository + .saveRecordAsDraft(record) + .pipe(map(() => undefined)) } } diff --git a/libs/feature/editor/src/lib/services/wizard.service.spec.ts b/libs/feature/editor/src/lib/services/wizard.service.spec.ts index 5d5063df4b..4d636de163 100644 --- a/libs/feature/editor/src/lib/services/wizard.service.spec.ts +++ b/libs/feature/editor/src/lib/services/wizard.service.spec.ts @@ -7,16 +7,6 @@ import { HttpClientTestingModule } from '@angular/common/http/testing' import { NO_ERRORS_SCHEMA } from '@angular/core' import { UiInputsModule } from '@geonetwork-ui/ui/inputs' -const localStorageMock = () => { - let storage = {} - return { - getItem: (key) => (key in storage ? storage[key] : null), - setItem: (key, value) => (storage[key] = value || ''), - removeItem: (key) => delete storage[key], - clear: () => (storage = {}), - } -} - describe('WizardService', () => { let service: WizardService @@ -34,8 +24,6 @@ describe('WizardService', () => { }) beforeEach(() => { - Object.defineProperty(window, 'localStorage', { value: localStorageMock() }) - window.localStorage.setItem( 'datafeeder-state', '{"1":{"step":4,"values":[{"id":"title","value":"title"},{"id":"abstract","value":"dataset"},{"id":"tags","value":"[{\\"display\\":\\"Faeroe Islands\\",\\"value\\":\\"Faeroe Islands\\"}]"},{"id":"dropdown","value":"\\"25000\\""},{"id":"description","value":"description"}]},"10":{"step":4,"values":[{"id":"title","value":"title"},{"id":"abstract","value":"dataset"},{"id":"tags","value":"[{\\"display\\":\\"Davis Sea\\",\\"value\\":\\"Davis Sea\\"}]"},{"id":"dropdown","value":"\\"50000\\""},{"id":"description","value":"desctription"}]}}' diff --git a/libs/feature/record/src/lib/state/mdview.effects.spec.ts b/libs/feature/record/src/lib/state/mdview.effects.spec.ts index 066c5abe60..a1f8a5e112 100644 --- a/libs/feature/record/src/lib/state/mdview.effects.spec.ts +++ b/libs/feature/record/src/lib/state/mdview.effects.spec.ts @@ -28,7 +28,7 @@ const full = { class RecordsRepositoryMock { aggregate = jest.fn(() => of(SAMPLE_AGGREGATIONS_RESULTS)) search = jest.fn(() => of(SAMPLE_SEARCH_RESULTS)) - getByUniqueIdentifier = jest.fn(() => of(DATASET_RECORDS[0])) + getRecord = jest.fn(() => of(DATASET_RECORDS[0])) getSimilarRecords = jest.fn(() => of(DATASET_RECORDS)) } @@ -82,7 +82,7 @@ describe('MdViewEffects', () => { }) describe('when api success and at no record found', () => { beforeEach(() => { - repository.getByUniqueIdentifier = jest.fn(() => of(null)) + repository.getRecord = jest.fn(() => of(null)) }) it('dispatch loadFullSuccess', () => { actions = hot('-a-|', { @@ -97,9 +97,7 @@ describe('MdViewEffects', () => { describe('when api fails', () => { beforeEach(() => { - repository.getByUniqueIdentifier = jest.fn(() => - throwError(() => new Error('api')) - ) + repository.getRecord = jest.fn(() => throwError(() => new Error('api'))) }) it('dispatch loadFullFailure', () => { actions = hot('-a-|', { diff --git a/libs/feature/record/src/lib/state/mdview.effects.ts b/libs/feature/record/src/lib/state/mdview.effects.ts index ac98891858..cc97d7918c 100644 --- a/libs/feature/record/src/lib/state/mdview.effects.ts +++ b/libs/feature/record/src/lib/state/mdview.effects.ts @@ -20,9 +20,7 @@ export class MdViewEffects { loadFullMetadata$ = createEffect(() => this.actions$.pipe( ofType(MdViewActions.loadFullMetadata), - switchMap(({ uuid }) => - this.recordsRepository.getByUniqueIdentifier(uuid) - ), + switchMap(({ uuid }) => this.recordsRepository.getRecord(uuid)), map((record) => { if (record === null) { return MdViewActions.loadFullMetadataFailure({ notFound: true }) diff --git a/libs/feature/search/src/lib/results-table/results-table.component.html b/libs/feature/search/src/lib/results-table/results-table.component.html index 8d157ef040..27c50e1217 100644 --- a/libs/feature/search/src/lib/results-table/results-table.component.html +++ b/libs/feature/search/src/lib/results-table/results-table.component.html @@ -34,7 +34,18 @@ record.metadata.title - {{ item.title }} +
+ {{ item.title }} + + dashboard.records.hasDraft + +
diff --git a/libs/feature/search/src/lib/results-table/results-table.component.spec.ts b/libs/feature/search/src/lib/results-table/results-table.component.spec.ts index 038bbf8f08..e45273531b 100644 --- a/libs/feature/search/src/lib/results-table/results-table.component.spec.ts +++ b/libs/feature/search/src/lib/results-table/results-table.component.spec.ts @@ -8,6 +8,7 @@ import { SearchFacade } from '../state/search.facade' import { SearchService } from '../utils/service/search.service' import { SelectionService } from '@geonetwork-ui/api/repository' import { TranslateModule } from '@ngx-translate/core' +import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface' class SearchFacadeMock { results$ = new BehaviorSubject(DATASET_RECORDS) @@ -26,6 +27,9 @@ class SelectionServiceMock { clearSelection = jest.fn() selectedRecordsIdentifiers$ = new BehaviorSubject([]) } +class RecordsRepositoryMock { + recordHasDraft = jest.fn(() => false) +} describe('ResultsTableComponent', () => { let component: ResultsTableComponent @@ -50,6 +54,10 @@ describe('ResultsTableComponent', () => { provide: SelectionService, useClass: SelectionServiceMock, }, + { + provide: RecordsRepositoryInterface, + useClass: RecordsRepositoryMock, + }, ], }).compileComponents() @@ -206,4 +214,14 @@ describe('ResultsTableComponent', () => { expect(clickedRecord).toEqual(DATASET_RECORDS[0]) }) }) + + describe('#hasDraft', () => { + it('calls the repository service', () => { + const record = DATASET_RECORDS[0] + component.hasDraft(record) + expect( + TestBed.inject(RecordsRepositoryInterface).recordHasDraft + ).toHaveBeenCalledWith('my-dataset-001') + }) + }) }) diff --git a/libs/feature/search/src/lib/results-table/results-table.component.ts b/libs/feature/search/src/lib/results-table/results-table.component.ts index be2f59b2e9..64f498ac98 100644 --- a/libs/feature/search/src/lib/results-table/results-table.component.ts +++ b/libs/feature/search/src/lib/results-table/results-table.component.ts @@ -20,6 +20,8 @@ import { CommonModule } from '@angular/common' import { map, take } from 'rxjs/operators' import { FieldSort } from '@geonetwork-ui/common/domain/model/search' import { SearchService } from '../utils/service/search.service' +import { BadgeComponent } from '@geonetwork-ui/ui/widgets' +import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface' @Component({ selector: 'gn-ui-results-table', @@ -33,6 +35,7 @@ import { SearchService } from '../utils/service/search.service' InteractiveTableColumnComponent, MatIconModule, TranslateModule, + BadgeComponent, ], }) export class ResultsTableComponent { @@ -44,7 +47,8 @@ export class ResultsTableComponent { constructor( private searchFacade: SearchFacade, private searchService: SearchService, - private selectionService: SelectionService + private selectionService: SelectionService, + private recordsRepository: RecordsRepositoryInterface ) {} dateToString(date: Date): string { @@ -161,4 +165,8 @@ export class ResultsTableComponent { }) ) } + + hasDraft(record: CatalogRecord): boolean { + return this.recordsRepository.recordHasDraft(record.uniqueIdentifier) + } } diff --git a/libs/ui/catalog/src/lib/language-switcher/language-switcher.component.spec.ts b/libs/ui/catalog/src/lib/language-switcher/language-switcher.component.spec.ts index 5c6a39f3d3..38fb3d77e8 100644 --- a/libs/ui/catalog/src/lib/language-switcher/language-switcher.component.spec.ts +++ b/libs/ui/catalog/src/lib/language-switcher/language-switcher.component.spec.ts @@ -3,21 +3,6 @@ import { LANGUAGE_STORAGE_KEY } from '@geonetwork-ui/util/i18n' import { TranslateService } from '@ngx-translate/core' import { LanguageSwitcherComponent } from './language-switcher.component' -export class LocalStorageRefStub { - store = {} - mockLocalStorage = { - getItem: (key: string): string => { - return key in this.store ? this.store[key] : null - }, - setItem: (key: string, value: string) => { - this.store[key] = `${value}` - }, - } - public getLocalStorage() { - return this.mockLocalStorage - } -} - class TranslateServiceMock { use = jest.fn() currentLang = 'en' @@ -43,10 +28,6 @@ describe('LanguageSwitcherComponent', () => { service = TestBed.inject(TranslateService) fixture = TestBed.createComponent(LanguageSwitcherComponent) component = fixture.componentInstance - - Object.defineProperty(window, 'localStorage', { - value: new LocalStorageRefStub().getLocalStorage(), - }) }) it('should create', () => { diff --git a/libs/ui/elements/src/lib/ui-elements.module.ts b/libs/ui/elements/src/lib/ui-elements.module.ts index 28d194adc9..336175d9b0 100644 --- a/libs/ui/elements/src/lib/ui-elements.module.ts +++ b/libs/ui/elements/src/lib/ui-elements.module.ts @@ -9,7 +9,7 @@ import { ContentGhostComponent } from './content-ghost/content-ghost.component' import { DownloadItemComponent } from './download-item/download-item.component' import { DownloadsListComponent } from './downloads-list/downloads-list.component' import { ApiCardComponent } from './api-card/api-card.component' -import { UiWidgetsModule } from '@geonetwork-ui/ui/widgets' +import { BadgeComponent, UiWidgetsModule } from '@geonetwork-ui/ui/widgets' import { UiLayoutModule } from '@geonetwork-ui/ui/layout' import { TranslateModule } from '@ngx-translate/core' import { RelatedRecordCardComponent } from './related-record-card/related-record-card.component' @@ -49,6 +49,7 @@ import { TimeSincePipe } from './user-feedback-item/time-since.pipe' MarkdownParserComponent, ThumbnailComponent, TimeSincePipe, + BadgeComponent, ], declarations: [ MetadataInfoComponent, diff --git a/libs/ui/widgets/src/lib/badge/badge.component.html b/libs/ui/widgets/src/lib/badge/badge.component.html index 8c439407a0..f5686c7d50 100644 --- a/libs/ui/widgets/src/lib/badge/badge.component.html +++ b/libs/ui/widgets/src/lib/badge/badge.component.html @@ -1,5 +1,5 @@
downloading + + + pest_control larger (with css) + + + different waves shape + + + different corners + + + different colors + +
`, }), } diff --git a/libs/ui/widgets/src/lib/badge/badge.component.ts b/libs/ui/widgets/src/lib/badge/badge.component.ts index 8d78ac1b34..cd2525068e 100644 --- a/libs/ui/widgets/src/lib/badge/badge.component.ts +++ b/libs/ui/widgets/src/lib/badge/badge.component.ts @@ -1,11 +1,14 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core' +import { CommonModule } from '@angular/common' @Component({ selector: 'gn-ui-badge', templateUrl: './badge.component.html', styleUrls: ['./badge.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CommonModule], }) export class BadgeComponent { - @Input() clickable? = false + @Input() clickable = false } diff --git a/libs/ui/widgets/src/lib/ui-widgets.module.ts b/libs/ui/widgets/src/lib/ui-widgets.module.ts index eda41bebf8..ab7cfa8db1 100644 --- a/libs/ui/widgets/src/lib/ui-widgets.module.ts +++ b/libs/ui/widgets/src/lib/ui-widgets.module.ts @@ -10,7 +10,6 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { LoadingMaskComponent } from './loading-mask/loading-mask.component' import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' import { PopupAlertComponent } from './popup-alert/popup-alert.component' -import { BadgeComponent } from './badge/badge.component' import { MatIconModule } from '@angular/material/icon' import { SpinningLoaderComponent } from './spinning-loader/spinning-loader.component' import { CommonModule } from '@angular/common' @@ -22,7 +21,6 @@ import { CommonModule } from '@angular/common' StepBarComponent, LoadingMaskComponent, PopupAlertComponent, - BadgeComponent, SpinningLoaderComponent, ], imports: [ @@ -41,7 +39,6 @@ import { CommonModule } from '@angular/common' StepBarComponent, LoadingMaskComponent, PopupAlertComponent, - BadgeComponent, SpinningLoaderComponent, ], }) diff --git a/tailwind.base.css b/tailwind.base.css index 016e4d3f4f..ee9c093999 100644 --- a/tailwind.base.css +++ b/tailwind.base.css @@ -104,7 +104,27 @@ border border-white focus:ring-4 focus:ring-gray-300; } - /* TODO: add prefix */ + /* BADGE CLASS */ + .gn-ui-badge { + --rounded: var(--gn-ui-badge-rounded, 0.25em); + --padding: var(--gn-ui-badge-padding, 0.375em 0.75em); + --text-color: var(--gn-ui-badge-text-color, var(--color-gray-50)); + --background-color: var(--gn-ui-badge-background-color, black); + @apply inline-block opacity-70 p-[--padding] rounded-[--rounded] + font-medium text-[length:0.875em] leading-none text-[color:--text-color] bg-[color:--background-color]; + } + /* makes sure icons will not make the badges grow vertically; also make size proportional */ + .gn-ui-badge mat-icon.mat-icon { + margin-top: -0.325em; + margin-bottom: -0.325em; + flex-shrink: 0; + vertical-align: middle; + font-size: 1.4em; + width: 1em; + height: 1em; + } + + /* TODO: replace by gn-ui-badge class above */ .badge-btn { @apply flex items-center justify-center px-4 py-1 text-white rounded backdrop-blur; } diff --git a/translations/de.json b/translations/de.json index 5a5e80f3f9..822d224de6 100644 --- a/translations/de.json +++ b/translations/de.json @@ -20,6 +20,7 @@ "dashboard.createRecord": "Neuer Eintrag", "dashboard.labels.mySpace": "Mein Bereich", "dashboard.records.all": "Katalog", + "dashboard.records.hasDraft": "", "dashboard.records.myDraft": "Meine Entwürfe", "dashboard.records.myLibrary": "Meine Bibliothek", "dashboard.records.myOrg": "Meine Organisation", @@ -167,6 +168,9 @@ "editor.record.publishError.title": "", "editor.record.publishSuccess.body": "", "editor.record.publishSuccess.title": "", + "editor.record.saveStatus.asDraftOnly": "", + "editor.record.saveStatus.draftWithChangesPending": "", + "editor.record.saveStatus.recordUpToDate": "", "editor.record.upToDate": "", "externalviewer.dataset.unnamed": "Datensatz aus dem Datahub", "facets.block.title.OrgForResource": "Organisation", @@ -334,8 +338,8 @@ "search.autocomplete.error": "Vorschläge konnten nicht abgerufen werden:", "search.error.couldNotReachApi": "Die API konnte nicht erreicht werden", "search.error.receivedError": "Ein Fehler ist aufgetreten", - "search.error.recordNotFound": "Der Datensatz mit der Kennung \"{ id }\" konnte nicht gefunden werden.", "search.error.recordHasnolink": "", + "search.error.recordNotFound": "Der Datensatz mit der Kennung \"{ id }\" konnte nicht gefunden werden.", "search.field.any.placeholder": "Suche Datensätze ...", "search.field.sortBy": "Sortieren nach:", "search.filters.clear": "Zurücksetzen", diff --git a/translations/en.json b/translations/en.json index 0e038a84f8..53e231e70e 100644 --- a/translations/en.json +++ b/translations/en.json @@ -20,6 +20,7 @@ "dashboard.createRecord": "New record", "dashboard.labels.mySpace": "My space", "dashboard.records.all": "Metadata records", + "dashboard.records.hasDraft": "draft", "dashboard.records.myDraft": "My drafts", "dashboard.records.myLibrary": "My library", "dashboard.records.myOrg": "Organization", @@ -167,6 +168,9 @@ "editor.record.publishError.title": "Error publishing record", "editor.record.publishSuccess.body": "The record was successfully published!", "editor.record.publishSuccess.title": "Publish success", + "editor.record.saveStatus.asDraftOnly": "Saved as draft only - not published yet", + "editor.record.saveStatus.draftWithChangesPending": "Saved as draft - changes are pending", + "editor.record.saveStatus.recordUpToDate": "Record is up to date", "editor.record.upToDate": "This record is up to date", "externalviewer.dataset.unnamed": "Datahub layer", "facets.block.title.OrgForResource": "Organisation", @@ -334,8 +338,8 @@ "search.autocomplete.error": "Suggestions could not be fetched:", "search.error.couldNotReachApi": "The API could not be reached", "search.error.receivedError": "An error was received", - "search.error.recordNotFound": "The record with identifier \"{ id }\" could not be found.", "search.error.recordHasnolink": "This record currently has no link yet, please come back later.", + "search.error.recordNotFound": "The record with identifier \"{ id }\" could not be found.", "search.field.any.placeholder": "Search datasets ...", "search.field.sortBy": "Sort by:", "search.filters.clear": "Reset", diff --git a/translations/es.json b/translations/es.json index efbc8c4b50..384aa3cba3 100644 --- a/translations/es.json +++ b/translations/es.json @@ -20,6 +20,7 @@ "dashboard.createRecord": "", "dashboard.labels.mySpace": "Mi espacio", "dashboard.records.all": "Catálogo", + "dashboard.records.hasDraft": "", "dashboard.records.myDraft": "Mis borradores", "dashboard.records.myLibrary": "Mi biblioteca", "dashboard.records.myOrg": "Organización", @@ -167,6 +168,9 @@ "editor.record.publishError.title": "", "editor.record.publishSuccess.body": "", "editor.record.publishSuccess.title": "", + "editor.record.saveStatus.asDraftOnly": "", + "editor.record.saveStatus.draftWithChangesPending": "", + "editor.record.saveStatus.recordUpToDate": "", "editor.record.upToDate": "", "externalviewer.dataset.unnamed": "", "facets.block.title.OrgForResource": "", @@ -334,8 +338,8 @@ "search.autocomplete.error": "", "search.error.couldNotReachApi": "", "search.error.receivedError": "", - "search.error.recordNotFound": "", "search.error.recordHasnolink": "", + "search.error.recordNotFound": "", "search.field.any.placeholder": "", "search.field.sortBy": "", "search.filters.clear": "", diff --git a/translations/fr.json b/translations/fr.json index debb06f6fb..8dc865f38f 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -20,6 +20,7 @@ "dashboard.createRecord": "Nouvel enregistrement", "dashboard.labels.mySpace": "Mon espace", "dashboard.records.all": "Catalogue", + "dashboard.records.hasDraft": "brouillon", "dashboard.records.myDraft": "Mes brouillons", "dashboard.records.myLibrary": "Ma bibliothèque", "dashboard.records.myOrg": "Mon organisation", @@ -167,6 +168,9 @@ "editor.record.publishError.title": "", "editor.record.publishSuccess.body": "", "editor.record.publishSuccess.title": "", + "editor.record.saveStatus.asDraftOnly": "Brouillon enregistré - fiche non publiée", + "editor.record.saveStatus.draftWithChangesPending": "Brouillon enregistré - modifications en cours", + "editor.record.saveStatus.recordUpToDate": "La fiche publiée est à jour", "editor.record.upToDate": "", "externalviewer.dataset.unnamed": "Couche du datahub", "facets.block.title.OrgForResource": "Organisation", @@ -334,8 +338,8 @@ "search.autocomplete.error": "Les suggestions ne peuvent pas être récupérées", "search.error.couldNotReachApi": "Problème de connexion à l'API", "search.error.receivedError": "Erreur retournée", - "search.error.recordNotFound": "Cette donnée n'a pu être trouvée.", "search.error.recordHasnolink": "Ce dataset n'a pas encore de lien, réessayez plus tard s'il vous plaît.", + "search.error.recordNotFound": "Cette donnée n'a pu être trouvée.", "search.field.any.placeholder": "Rechercher une donnée...", "search.field.sortBy": "Trier par :", "search.filters.clear": "Réinitialiser", diff --git a/translations/it.json b/translations/it.json index 209c87ab2e..34ec4c3674 100644 --- a/translations/it.json +++ b/translations/it.json @@ -20,6 +20,7 @@ "dashboard.createRecord": "Crea un record", "dashboard.labels.mySpace": "Il mio spazio", "dashboard.records.all": "Catalogo", + "dashboard.records.hasDraft": "", "dashboard.records.myDraft": "Le mie bozze", "dashboard.records.myLibrary": "La mia biblioteca", "dashboard.records.myOrg": "La mia organizzazione", @@ -167,6 +168,9 @@ "editor.record.publishError.title": "", "editor.record.publishSuccess.body": "", "editor.record.publishSuccess.title": "", + "editor.record.saveStatus.asDraftOnly": "", + "editor.record.saveStatus.draftWithChangesPending": "", + "editor.record.saveStatus.recordUpToDate": "", "editor.record.upToDate": "", "externalviewer.dataset.unnamed": "Layer del datahub", "facets.block.title.OrgForResource": "Organizzazione", @@ -334,8 +338,8 @@ "search.autocomplete.error": "Impossibile recuperare le suggerimenti", "search.error.couldNotReachApi": "Problema di connessione all'API", "search.error.receivedError": "Errore ricevuto", - "search.error.recordNotFound": "Impossibile trovare questo dato", "search.error.recordHasnolink": "", + "search.error.recordNotFound": "Impossibile trovare questo dato", "search.field.any.placeholder": "Cerca un dato...", "search.field.sortBy": "Ordina per:", "search.filters.clear": "Ripristina", diff --git a/translations/nl.json b/translations/nl.json index 424abeffc5..98078f8718 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -20,6 +20,7 @@ "dashboard.createRecord": "", "dashboard.labels.mySpace": "Mijn ruimte", "dashboard.records.all": "Catalogus", + "dashboard.records.hasDraft": "", "dashboard.records.myDraft": "Mijn concepten", "dashboard.records.myLibrary": "Mijn bibliotheek", "dashboard.records.myOrg": "Organisatie", @@ -167,6 +168,9 @@ "editor.record.publishError.title": "", "editor.record.publishSuccess.body": "", "editor.record.publishSuccess.title": "", + "editor.record.saveStatus.asDraftOnly": "", + "editor.record.saveStatus.draftWithChangesPending": "", + "editor.record.saveStatus.recordUpToDate": "", "editor.record.upToDate": "", "externalviewer.dataset.unnamed": "", "facets.block.title.OrgForResource": "", @@ -334,8 +338,8 @@ "search.autocomplete.error": "", "search.error.couldNotReachApi": "", "search.error.receivedError": "", - "search.error.recordNotFound": "", "search.error.recordHasnolink": "", + "search.error.recordNotFound": "", "search.field.any.placeholder": "", "search.field.sortBy": "", "search.filters.clear": "", diff --git a/translations/pt.json b/translations/pt.json index 9ec6b6be38..46a680eb6e 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -20,6 +20,7 @@ "dashboard.createRecord": "", "dashboard.labels.mySpace": "Meu espaço", "dashboard.records.all": "Catálogo", + "dashboard.records.hasDraft": "", "dashboard.records.myDraft": "Meus rascunhos", "dashboard.records.myLibrary": "Minha biblioteca", "dashboard.records.myOrg": "Organização", @@ -167,6 +168,9 @@ "editor.record.publishError.title": "", "editor.record.publishSuccess.body": "", "editor.record.publishSuccess.title": "", + "editor.record.saveStatus.asDraftOnly": "", + "editor.record.saveStatus.draftWithChangesPending": "", + "editor.record.saveStatus.recordUpToDate": "", "editor.record.upToDate": "", "externalviewer.dataset.unnamed": "", "facets.block.title.OrgForResource": "", @@ -334,8 +338,8 @@ "search.autocomplete.error": "", "search.error.couldNotReachApi": "", "search.error.receivedError": "", - "search.error.recordNotFound": "", "search.error.recordHasnolink": "", + "search.error.recordNotFound": "", "search.field.any.placeholder": "", "search.field.sortBy": "", "search.filters.clear": "", diff --git a/translations/sk.json b/translations/sk.json index b7a103afe4..02131776ed 100644 --- a/translations/sk.json +++ b/translations/sk.json @@ -20,6 +20,7 @@ "dashboard.createRecord": "", "dashboard.labels.mySpace": "Môj priestor", "dashboard.records.all": "Katalóg", + "dashboard.records.hasDraft": "", "dashboard.records.myDraft": "Moje koncepty", "dashboard.records.myLibrary": "Moja knižnica", "dashboard.records.myOrg": "Organizácia", @@ -167,6 +168,9 @@ "editor.record.publishError.title": "", "editor.record.publishSuccess.body": "", "editor.record.publishSuccess.title": "", + "editor.record.saveStatus.asDraftOnly": "", + "editor.record.saveStatus.draftWithChangesPending": "", + "editor.record.saveStatus.recordUpToDate": "", "editor.record.upToDate": "", "externalviewer.dataset.unnamed": "", "facets.block.title.OrgForResource": "Organizácia", @@ -334,8 +338,8 @@ "search.autocomplete.error": "Návrhy sa nepodarilo načítať:", "search.error.couldNotReachApi": "K rozhraniu API sa nepodarilo pripojiť", "search.error.receivedError": "Bola zaznamenaná chyba", - "search.error.recordNotFound": "Záznam s identifikátorom \"{ id }\" sa nepodarilo nájsť.", "search.error.recordHasnolink": "", + "search.error.recordNotFound": "Záznam s identifikátorom \"{ id }\" sa nepodarilo nájsť.", "search.field.any.placeholder": "Hľadať datasety ...", "search.field.sortBy": "Zoradiť podľa:", "search.filters.clear": "Obnoviť",