diff --git a/apps/metadata-editor-e2e/src/e2e/record-actions.cy.ts b/apps/metadata-editor-e2e/src/e2e/record-actions.cy.ts index 76dd781865..c4a9907dc9 100644 --- a/apps/metadata-editor-e2e/src/e2e/record-actions.cy.ts +++ b/apps/metadata-editor-e2e/src/e2e/record-actions.cy.ts @@ -317,4 +317,67 @@ describe('record-actions', () => { }) }) }) + describe('drafting', () => { + let recordUuid: any + describe('if a user edits the record in the meantime', () => { + beforeEach(() => { + cy.visit('/edit/9e1ea778-d0ce-4b49-90b7-37bc0e448300') + cy.url().should('include', '/edit/') + cy.editor_readFormUniqueIdentifier().then((uuid) => { + recordUuid = uuid + cy.wrap(uuid).as('recordUuid') + }) + cy.get('gn-ui-form-field[ng-reflect-model=abstract] textarea').as( + 'abstractField' + ) + cy.get('@abstractField').clear() + cy.get('@abstractField').type('modified abstract') + cy.editor_findDraftInLocalStorage().then((value) => { + expect(value).to.contain('modified abstract') + }) + cy.editor_wrapFirstDraft() + cy.clearRecordDrafts() + cy.visit('/edit/9e1ea778-d0ce-4b49-90b7-37bc0e448300') + cy.editor_wrapPreviousDraft() + cy.get('gn-ui-form-field[ng-reflect-model=abstract] textarea').as( + 'abstractField' + ) + cy.get('@abstractField').clear() + cy.get('@abstractField').type('modified by someone else') + cy.editor_publishAndReload() + cy.window().then((win) => { + cy.get('@firstDraft').then((firstDraft) => { + return win.localStorage.setItem( + `geonetwork-ui-draft-${recordUuid}`, + firstDraft.toString() + ) + }) + }) + cy.visit('/edit/9e1ea778-d0ce-4b49-90b7-37bc0e448300') + }) + it('should show the warning banner and the warning menu when publishing', () => { + cy.get('[data-test="draft-alert"]').should('be.visible') + cy.get('md-editor-publish-button').click() + cy.get('[data-test="publish-warning"]').should('be.visible') + }) + }) + describe('if nobody edits the record in the meantime', () => { + beforeEach(() => { + cy.clearRecordDrafts() + cy.visit('/edit/9e1ea778-d0ce-4b49-90b7-37bc0e448300') + cy.get('gn-ui-form-field[ng-reflect-model=abstract] textarea').as( + 'abstractField' + ) + cy.get('@abstractField').clear() + cy.get('@abstractField').type('modified abstract') + cy.visit('/catalog/search') + }) + it('should not show any warning', () => { + cy.visit('/edit/9e1ea778-d0ce-4b49-90b7-37bc0e448300') + cy.get('[data-test="draft-alert"]').should('not.exist') + cy.get('md-editor-publish-button').click() + cy.get('[data-test="publish-warning"]').should('not.exist') + }) + }) + }) }) diff --git a/apps/metadata-editor/src/app/edit/components/publish-button/publish-button.component.html b/apps/metadata-editor/src/app/edit/components/publish-button/publish-button.component.html index f08b021ff7..ef09afaa7a 100644 --- a/apps/metadata-editor/src/app/edit/components/publish-button/publish-button.component.html +++ b/apps/metadata-editor/src/app/edit/components/publish-button/publish-button.component.html @@ -1,8 +1,10 @@ @@ -11,12 +13,49 @@ + +
+ editor.record.publish.confirmation.message +
+ {{ + 'editor.record.publish.confirmation.cancelText' | translate + }} + {{ + 'editor.record.publish.confirmation.confirmText' | translate + }} +
+
+
diff --git a/apps/metadata-editor/src/app/edit/components/publish-button/publish-button.component.spec.ts b/apps/metadata-editor/src/app/edit/components/publish-button/publish-button.component.spec.ts index f8e904a00f..8e093d8ee1 100644 --- a/apps/metadata-editor/src/app/edit/components/publish-button/publish-button.component.spec.ts +++ b/apps/metadata-editor/src/app/edit/components/publish-button/publish-button.component.spec.ts @@ -1,7 +1,12 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing' +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing' import { PublishButtonComponent } from './publish-button.component' import { EditorFacade } from '@geonetwork-ui/feature/editor' -import { BehaviorSubject, firstValueFrom, of } from 'rxjs' +import { BehaviorSubject, Subject, firstValueFrom, of } from 'rxjs' import { TranslateModule } from '@ngx-translate/core' import { HttpClientModule } from '@angular/common/http' import { PlatformServiceInterface } from '@geonetwork-ui/common/domain/platform.service.interface' @@ -10,6 +15,7 @@ import { RecordsApiService, } from '@geonetwork-ui/data-access/gn4' import { barbieUserFixture } from '@geonetwork-ui/common/fixtures' +import { OverlayRef } from '@angular/cdk/overlay' class EditorFacadeMock { changedSinceSave$ = new BehaviorSubject(false) @@ -18,9 +24,26 @@ class EditorFacadeMock { record$ = new BehaviorSubject({ ownerOrganization: { name: 'Group 1', id: 1 }, uniqueIdentifier: 304, + recordUpdated: new Date('2023-01-01'), + extras: { ownerInfo: '1|John|Doe' }, }) saveRecord = jest.fn() saveSuccess$ = new BehaviorSubject(true) + checkHasRecordChanged = jest.fn() + hasRecordChanged$ = new Subject() + isRecordNotYetSaved = jest.fn().mockReturnValue(false) + recordHasDraft = jest.fn().mockReturnValue(true) + getAllDrafts = jest + .fn() + .mockReturnValue( + of([{ uniqueIdentifier: 304, recordUpdated: new Date('2023-01-01') }]) + ) + getRecord = jest.fn().mockReturnValue( + of({ + recordUpdated: new Date('2023-02-01'), + extras: { ownerInfo: '1|John|Doe' }, + }) + ) } const user = barbieUserFixture() @@ -47,6 +70,7 @@ describe('PublishButtonComponent', () => { let fixture: ComponentFixture let facade: EditorFacadeMock let recordsApiService: RecordsApiService + let overlaySpy: any beforeEach(async () => { await TestBed.configureTestingModule({ @@ -79,6 +103,12 @@ describe('PublishButtonComponent', () => { facade = TestBed.inject(EditorFacade) as any fixture = TestBed.createComponent(PublishButtonComponent) component = fixture.componentInstance + overlaySpy = { + dispose: jest.fn(), + attach: jest.fn(), + backdropClick: jest.fn().mockReturnValue(of()), + } + component['overlayRef'] = overlaySpy fixture.detectChanges() }) @@ -127,4 +157,51 @@ describe('PublishButtonComponent', () => { ) }) }) + describe('#confirmPublish', () => { + it('should call saveRecord', () => { + const saveRecordSpy = jest.spyOn(component, 'saveRecord') + component.confirmPublish() + expect(saveRecordSpy).toHaveBeenCalled() + }) + }) + + describe('#cancelPublish', () => { + it('should set isActionMenuOpen to false', () => { + component.isActionMenuOpen = true + component.cancelPublish() + expect(component.isActionMenuOpen).toBe(false) + }) + }) + + describe('#verifyPublishConditions', () => { + it('should call openConfirmationMenu if hasRecordChanged emits with a date', () => { + const openConfirmationMenuSpy = jest.spyOn( + component, + 'openConfirmationMenu' + ) + const saveRecordSpy = jest.spyOn(component, 'saveRecord') + + component.verifyPublishConditions() + facade.hasRecordChanged$.next(null) + facade.hasRecordChanged$.next({ date: new Date(), user: 'John Doe' }) + + expect(openConfirmationMenuSpy).toHaveBeenCalled() + expect(saveRecordSpy).not.toHaveBeenCalled() + }) + + it('should call saveRecord if hasRecordChanged emits without a date', () => { + const openConfirmationMenuSpy = jest.spyOn( + component, + 'openConfirmationMenu' + ) + const saveRecordSpy = jest.spyOn(component, 'saveRecord') + + component.verifyPublishConditions() + facade.hasRecordChanged$.next(null) + facade.hasRecordChanged$.next({ date: undefined, user: undefined }) + + expect(saveRecordSpy).toHaveBeenCalled() + expect(openConfirmationMenuSpy).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/metadata-editor/src/app/edit/components/publish-button/publish-button.component.ts b/apps/metadata-editor/src/app/edit/components/publish-button/publish-button.component.ts index 8442bdc6ef..31d4eb09d3 100644 --- a/apps/metadata-editor/src/app/edit/components/publish-button/publish-button.component.ts +++ b/apps/metadata-editor/src/app/edit/components/publish-button/publish-button.component.ts @@ -1,14 +1,28 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core' +import { + CdkConnectedOverlay, + CdkOverlayOrigin, + Overlay, + OverlayRef, +} from '@angular/cdk/overlay' +import { TemplatePortal } from '@angular/cdk/portal' import { CommonModule } from '@angular/common' -import { ButtonComponent } from '@geonetwork-ui/ui/inputs' +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + OnDestroy, + TemplateRef, + ViewChild, + ViewContainerRef, +} from '@angular/core' +import { MatMenuTrigger } from '@angular/material/menu' import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' -import { EditorFacade } from '@geonetwork-ui/feature/editor' import { MatTooltipModule } from '@angular/material/tooltip' -import { TranslateModule } from '@ngx-translate/core' -import { combineLatest, Observable } from 'rxjs' -import { map, switchMap, take } from 'rxjs/operators' -import { RecordsApiService } from '@geonetwork-ui/data-access/gn4' import { PlatformServiceInterface } from '@geonetwork-ui/common/domain/platform.service.interface' +import { RecordsApiService } from '@geonetwork-ui/data-access/gn4' +import { EditorFacade } from '@geonetwork-ui/feature/editor' +import { ButtonComponent } from '@geonetwork-ui/ui/inputs' import { NgIconComponent, provideIcons, @@ -16,9 +30,19 @@ import { } from '@ng-icons/core' import { iconoirCloudUpload } from '@ng-icons/iconoir' import { matCheckCircleOutline } from '@ng-icons/material-icons/outline' +import { TranslateModule, TranslateService } from '@ngx-translate/core' +import { combineLatest, Observable, of, Subscription } from 'rxjs' +import { + catchError, + concatMap, + map, + skip, + switchMap, + take, + withLatestFrom, +} from 'rxjs/operators' export type RecordSaveStatus = 'saving' | 'upToDate' | 'hasChanges' - @Component({ selector: 'md-editor-publish-button', standalone: true, @@ -29,6 +53,8 @@ export type RecordSaveStatus = 'saving' | 'upToDate' | 'hasChanges' MatTooltipModule, TranslateModule, NgIconComponent, + CdkOverlayOrigin, + CdkConnectedOverlay, ], providers: [ provideIcons({ iconoirCloudUpload, matCheckCircleOutline }), @@ -40,7 +66,8 @@ export type RecordSaveStatus = 'saving' | 'upToDate' | 'hasChanges' styleUrls: ['./publish-button.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PublishButtonComponent { +export class PublishButtonComponent implements OnDestroy { + subscription = new Subscription() status$: Observable = combineLatest([ this.facade.changedSinceSave$, this.facade.saving$, @@ -58,12 +85,103 @@ export class PublishButtonComponent { record$ = this.facade.record$ + @ViewChild(MatMenuTrigger) trigger: MatMenuTrigger + + @ViewChild('actionMenuButton', { read: ElementRef }) + actionMenuButton!: ElementRef + @ViewChild('template') template!: TemplateRef + private overlayRef!: OverlayRef + + isActionMenuOpen = false + publishWarning = null + constructor( private facade: EditorFacade, private recordsApiService: RecordsApiService, - private platformService: PlatformServiceInterface + private platformService: PlatformServiceInterface, + private overlay: Overlay, + private viewContainerRef: ViewContainerRef, + private cdr: ChangeDetectorRef, + private translateService: TranslateService ) {} + ngOnDestroy() { + this.subscription.unsubscribe() + } + + confirmPublish() { + this.saveRecord() + this.closeMenu() + } + + cancelPublish() { + if (this.overlayRef) { + this.closeMenu() + } + } + + closeMenu() { + this.isActionMenuOpen = false + this.overlayRef.dispose() + this.cdr.markForCheck() + } + + openConfirmationMenu() { + this.isActionMenuOpen = true + const positionStrategy = this.overlay + .position() + .flexibleConnectedTo(this.actionMenuButton) + .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.cancelPublish() + }) + } + + verifyPublishConditions() { + this.facade.hasRecordChanged$ + .pipe( + skip(1), + take(1), + catchError(() => of({ user: undefined, date: undefined })) + ) + .subscribe((hasChanged) => { + if (hasChanged?.date) { + this.publishWarning = hasChanged + this.openConfirmationMenu() + } else { + this.saveRecord() + } + }) + + this.facade.record$ + .pipe( + take(1), + map((record) => { + this.facade.checkHasRecordChanged(record) + }) + ) + .subscribe() + } + saveRecord() { this.facade.saveRecord() this.facade.saveSuccess$ @@ -84,4 +202,14 @@ export class PublishButtonComponent { ) .subscribe() } + + formatDate(date: Date): string { + return date.toLocaleDateString(this.translateService.currentLang, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }) + } } 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 afd16d6833..129b8bc541 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 @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common' -import { ChangeDetectionStrategy, Component } from '@angular/core' +import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { MatDialog, MatDialogModule } from '@angular/material/dialog' import { MatTooltipModule } from '@angular/material/tooltip' import { EditorFacade } from '@geonetwork-ui/feature/editor' diff --git a/apps/metadata-editor/src/app/edit/edit-page.component.html b/apps/metadata-editor/src/app/edit/edit-page.component.html index 7c41e5a789..01423fd7e3 100644 --- a/apps/metadata-editor/src/app/edit/edit-page.component.html +++ b/apps/metadata-editor/src/app/edit/edit-page.component.html @@ -5,12 +5,26 @@
-
+
+
+
+ editor.record.form.draft.updateAlert +
+
{ }) describe('subscriptions', () => { - it('should add 3 subscriptions to component.subscription', () => { + it('should add 5 subscriptions to component.subscription', () => { const addSpy = jest.spyOn(component.subscription, 'add') component.ngOnInit() - expect(addSpy).toHaveBeenCalledTimes(3) + expect(addSpy).toHaveBeenCalledTimes(5) }) - it('should add 4 subscriptions to component.subscription when on /create route', () => { + it('should add 6 subscriptions to component.subscription when on /create route', () => { const activatedRoute = TestBed.inject(ActivatedRoute) activatedRoute.snapshot.routeConfig.path = '/create' fixture.detectChanges() const addSpy = jest.spyOn(component.subscription, 'add') component.ngOnInit() - expect(addSpy).toHaveBeenCalledTimes(4) + expect(addSpy).toHaveBeenCalledTimes(6) }) it('unsubscribes', () => { const unsubscribeSpy = jest.spyOn(component.subscription, 'unsubscribe') 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 bead8d580e..b2f46d20c5 100644 --- a/apps/metadata-editor/src/app/edit/edit-page.component.ts +++ b/apps/metadata-editor/src/app/edit/edit-page.component.ts @@ -21,7 +21,7 @@ import { import { ButtonComponent } from '@geonetwork-ui/ui/inputs' import { TranslateModule, TranslateService } from '@ngx-translate/core' import { combineLatest, filter, firstValueFrom, Subscription, take } from 'rxjs' -import { map } from 'rxjs/operators' +import { map, skip } from 'rxjs/operators' import { SidebarComponent } from '../dashboard/sidebar/sidebar.component' import { PageSelectorComponent } from './components/page-selector/page-selector.component' import { TopToolbarComponent } from './components/top-toolbar/top-toolbar.component' @@ -57,6 +57,7 @@ export class EditPageComponent implements OnInit, OnDestroy { isLastPage$ = combineLatest([this.currentPage$, this.pagesLength$]).pipe( map(([currentPage, pagesCount]) => currentPage >= pagesCount - 1) ) + hasRecordChanged$ = this.facade.hasRecordChanged$.pipe(skip(1)) @ViewChild('scrollContainer') scrollContainer: ElementRef @@ -136,6 +137,12 @@ export class EditPageComponent implements OnInit, OnDestroy { }) ) + this.subscription.add( + this.facade.record$.subscribe((record) => { + this.facade.checkHasRecordChanged(record) + }) + ) + // if we're on the /create route, go to /edit/{uuid} on first change if (this.route.snapshot.routeConfig?.path.includes('create')) { this.subscription.add( @@ -161,6 +168,12 @@ export class EditPageComponent implements OnInit, OnDestroy { this.router.navigate(['edit', savedRecord.uniqueIdentifier]) }) ) + + this.subscription.add( + this.facade.record$.subscribe((record) => { + this.facade.checkHasRecordChanged(record) + }) + ) } ngOnDestroy() { @@ -192,4 +205,14 @@ export class EditPageComponent implements OnInit, OnDestroy { top: 0, }) } + + formatDate(date: Date): string { + return date.toLocaleDateString(this.translateService.currentLang, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }) + } } diff --git a/apps/metadata-editor/src/app/records/my-draft/my-draft.component.html b/apps/metadata-editor/src/app/records/my-draft/my-draft.component.html index fdfb8f2069..fc400041ce 100644 --- a/apps/metadata-editor/src/app/records/my-draft/my-draft.component.html +++ b/apps/metadata-editor/src/app/records/my-draft/my-draft.component.html @@ -9,6 +9,7 @@

class="shadow-md shadow-gray-300 border-[1px] border-gray-200 overflow-hidden rounded bg-white grow mx-[32px] my-[16px] text-sm" > @@ -97,6 +98,17 @@ class PlatformServiceInterfaceMock { getApiVersion = jest.fn(() => of('4.2.5')) } +const SAMPLE_RECORD = { + ...datasetRecordsFixture()[0], + extras: { + ownerInfo: 'Owner|SomeDetails', + }, +} + +const translateServiceMock = { + currentLang: 'fr', +} + describe('Gn4Repository', () => { let repository: Gn4Repository let gn4Helper: ElasticsearchService @@ -130,6 +142,10 @@ describe('Gn4Repository', () => { provide: PlatformServiceInterface, useClass: PlatformServiceInterfaceMock, }, + { + provide: TranslateService, + useValue: translateServiceMock, + }, ], }) repository = TestBed.inject(Gn4Repository) @@ -749,4 +765,80 @@ describe('Gn4Repository', () => { expect(repository.isRecordNotYetSaved('1234-5678')).toBe(false) }) }) + describe('hasRecordChangedSinceDraft', () => { + it('should return an empty array if the record is unsaved', () => { + // Mock dependencies + repository.isRecordNotYetSaved = jest.fn().mockReturnValue(true) + repository.recordHasDraft = jest.fn().mockReturnValue(true) + + repository + .hasRecordChangedSinceDraft(SAMPLE_RECORD) + .subscribe((result) => { + expect(result).toEqual([]) + }) + }) + + it('should return an empty array if there is no draft', () => { + // Mock dependencies + repository.isRecordNotYetSaved = jest.fn().mockReturnValue(false) + repository.recordHasDraft = jest.fn().mockReturnValue(false) + + repository + .hasRecordChangedSinceDraft(SAMPLE_RECORD) + .subscribe((result) => { + expect(result).toEqual([]) + }) + }) + + it('should return updated date and owner info if the recent record is newer than the draft', () => { + const mockDrafts = [ + { + uniqueIdentifier: 'my-dataset-001', + recordUpdated: new Date('2023-01-01'), + }, + ] + const mockRecentRecord = { + uniqueIdentifier: 'my-dataset-001', + recordUpdated: new Date('2024-01-01'), + extras: { ownerInfo: 'Owner|SomeDetails' }, + } + + // Mock dependencies + repository.isRecordNotYetSaved = jest.fn().mockReturnValue(false) + repository.recordHasDraft = jest.fn().mockReturnValue(true) + repository.getAllDrafts = jest.fn().mockReturnValue(of(mockDrafts)) + repository.getRecord = jest.fn().mockReturnValue(of(mockRecentRecord)) + + repository + .hasRecordChangedSinceDraft(SAMPLE_RECORD) + .subscribe((result) => { + expect(result).toEqual([expect.any(String), 'Owner']) + }) + }) + + it('should return an empty array if the draft is more recent than the recent record', () => { + const mockDrafts = [ + { + uniqueIdentifier: 'my-dataset-001', + recordUpdated: new Date('2024-01-01'), + }, + ] + const mockRecentRecord = { + uniqueIdentifier: 'my-dataset-001', + recordUpdated: new Date('2023-01-01'), + } + + // Mock dependencies + repository.isRecordNotYetSaved = jest.fn().mockReturnValue(false) + repository.recordHasDraft = jest.fn().mockReturnValue(true) + repository.getAllDrafts = jest.fn().mockReturnValue(of(mockDrafts)) + repository.getRecord = jest.fn().mockReturnValue(of(mockRecentRecord)) + + repository + .hasRecordChangedSinceDraft(SAMPLE_RECORD) + .subscribe((result) => { + expect(result).toEqual([]) + }) + }) + }) }) diff --git a/libs/api/repository/src/lib/gn4/gn4-repository.ts b/libs/api/repository/src/lib/gn4/gn4-repository.ts index 64068b7f02..c26d444852 100644 --- a/libs/api/repository/src/lib/gn4/gn4-repository.ts +++ b/libs/api/repository/src/lib/gn4/gn4-repository.ts @@ -31,6 +31,7 @@ import { import { combineLatest, exhaustMap, + forkJoin, from, Observable, of, @@ -365,6 +366,44 @@ export class Gn4Repository implements RecordsRepositoryInterface { return of(draftCount) } + hasRecordChangedSinceDraft(localRecord: CatalogRecord) { + return of({ + isUnsaved: this.isRecordNotYetSaved(localRecord.uniqueIdentifier), + hasDraft: this.recordHasDraft(localRecord.uniqueIdentifier), + }).pipe( + switchMap(({ isUnsaved, hasDraft }) => { + if (isUnsaved || !hasDraft) { + return of({ user: undefined, date: undefined }) + } + return forkJoin([ + this.getAllDrafts().pipe( + map((drafts) => { + const matchingRecord = drafts.find( + (draft) => + draft.uniqueIdentifier === localRecord.uniqueIdentifier + ) + return matchingRecord?.recordUpdated || null + }) + ), + this.getRecord(localRecord.uniqueIdentifier), + ]).pipe( + map(([draftRecordUpdated, recentRecord]) => { + if (recentRecord?.recordUpdated > draftRecordUpdated) { + const user = recentRecord.extras?.['ownerInfo'] + ?.toString() + ?.split('|') + return { + user: `${user[2]} ${user[1]}`, + date: recentRecord.recordUpdated, + } + } + return { user: undefined, date: undefined } + }) + ) + }) + ) + } + private getRecordAsXml(uniqueIdentifier: string): Observable { return this.gn4RecordsApi .getRecordAs( 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 b2640d069c..f04d0d5ed7 100644 --- a/libs/common/domain/src/lib/repository/records-repository.interface.ts +++ b/libs/common/domain/src/lib/repository/records-repository.interface.ts @@ -88,4 +88,7 @@ export abstract class RecordsRepositoryInterface { abstract getAllDrafts(): Observable abstract getDraftsCount(): Observable abstract draftsChanged$: Observable + abstract hasRecordChangedSinceDraft( + localRecord: CatalogRecord + ): Observable<{ user: string; date: Date }> } diff --git a/libs/feature/editor/src/lib/+state/editor.actions.ts b/libs/feature/editor/src/lib/+state/editor.actions.ts index 85e02ce54e..4130e74d5c 100644 --- a/libs/feature/editor/src/lib/+state/editor.actions.ts +++ b/libs/feature/editor/src/lib/+state/editor.actions.ts @@ -41,3 +41,13 @@ export const setFieldVisibility = createAction( '[Editor] Set field visibility', props<{ field: EditorFieldIdentification; visible: boolean }>() ) + +export const hasRecordChangedSinceDraft = createAction( + '[Editor] Has Record Changed Since Draft', + props<{ record: CatalogRecord }>() +) + +export const hasRecordChangedSinceDraftSuccess = createAction( + '[Editor] Has Record Changed Since Draft Success', + props<{ changes: { user: string; date: Date } }>() +) 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 5ee8316314..124cf4cf26 100644 --- a/libs/feature/editor/src/lib/+state/editor.effects.spec.ts +++ b/libs/feature/editor/src/lib/+state/editor.effects.spec.ts @@ -16,6 +16,7 @@ import { Gn4PlatformService } from '@geonetwork-ui/api/repository' class EditorServiceMock { saveRecord = jest.fn((record) => of([record, 'blabla'])) saveRecordAsDraft = jest.fn(() => of('blabla')) + hasRecordChangedSinceDraft = jest.fn((record) => of(['change1', 'change2'])) } class RecordsRepositoryMock { recordHasDraft = jest.fn(() => true) @@ -205,4 +206,19 @@ describe('EditorEffects', () => { }) }) }) + describe('hasRecordChangedSinceDraft$', () => { + it('dispatches hasRecordChangedSinceDraftSuccess on success', () => { + const record = datasetRecordsFixture()[0] + actions = hot('-a-|', { + a: EditorActions.hasRecordChangedSinceDraft({ record }), + }) + const expected = hot('-a-|', { + a: EditorActions.hasRecordChangedSinceDraftSuccess({ + changes: ['change1', 'change2'], + }), + }) + expect(effects.hasRecordChangedSinceDraft$).toBeObservable(expected) + expect(service.hasRecordChangedSinceDraft).toHaveBeenCalledWith(record) + }) + }) }) diff --git a/libs/feature/editor/src/lib/+state/editor.effects.ts b/libs/feature/editor/src/lib/+state/editor.effects.ts index cb678bf6df..4513b2e013 100644 --- a/libs/feature/editor/src/lib/+state/editor.effects.ts +++ b/libs/feature/editor/src/lib/+state/editor.effects.ts @@ -126,4 +126,19 @@ export class EditorEffects { map(() => EditorActions.markRecordAsChanged()) ) ) + + hasRecordChangedSinceDraft$ = createEffect(() => + this.actions$.pipe( + ofType(EditorActions.hasRecordChangedSinceDraft), + switchMap(({ record }) => + this.editorService + .hasRecordChangedSinceDraft(record) + .pipe( + map((changes) => + EditorActions.hasRecordChangedSinceDraftSuccess({ changes }) + ) + ) + ) + ) + ) } diff --git a/libs/feature/editor/src/lib/+state/editor.facade.spec.ts b/libs/feature/editor/src/lib/+state/editor.facade.spec.ts index 663cc1b13b..e4e355056d 100644 --- a/libs/feature/editor/src/lib/+state/editor.facade.spec.ts +++ b/libs/feature/editor/src/lib/+state/editor.facade.spec.ts @@ -78,5 +78,12 @@ describe('EditorFacade', () => { }) expect(spy).toHaveBeenCalledWith(action) }) + it('checkHasRecordChanged() should dispatch hasRecordChangedSinceDraft action', () => { + const spy = jest.spyOn(store, 'dispatch') + const record = datasetRecordsFixture()[0] + facade.checkHasRecordChanged(record) + const action = EditorActions.hasRecordChangedSinceDraft({ record }) + expect(spy).toHaveBeenCalledWith(action) + }) }) }) diff --git a/libs/feature/editor/src/lib/+state/editor.facade.ts b/libs/feature/editor/src/lib/+state/editor.facade.ts index 1b3f6dfb69..2c6e0400ee 100644 --- a/libs/feature/editor/src/lib/+state/editor.facade.ts +++ b/libs/feature/editor/src/lib/+state/editor.facade.ts @@ -32,6 +32,9 @@ export class EditorFacade { draftSaveSuccess$ = this.actions$.pipe(ofType(EditorActions.draftSaveSuccess)) currentPage$ = this.store.pipe(select(EditorSelectors.selectCurrentPage)) editorConfig$ = this.store.pipe(select(EditorSelectors.selectEditorConfig)) + hasRecordChanged$ = this.store.pipe( + select(EditorSelectors.selectHasRecordChanged) + ) openRecord( record: CatalogRecord, @@ -63,4 +66,8 @@ export class EditorFacade { setFieldVisibility(field: EditorFieldIdentification, visible: boolean) { this.store.dispatch(EditorActions.setFieldVisibility({ field, visible })) } + + checkHasRecordChanged(record: CatalogRecord) { + this.store.dispatch(EditorActions.hasRecordChangedSinceDraft({ record })) + } } 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 21585d40fc..af57bd953a 100644 --- a/libs/feature/editor/src/lib/+state/editor.reducer.spec.ts +++ b/libs/feature/editor/src/lib/+state/editor.reducer.spec.ts @@ -113,6 +113,18 @@ describe('Editor Reducer', () => { expect(result.changedSinceSave).toBe(true) }) + it('hasRecordChangedSinceDraftSuccess action', () => { + const changes = ['change1', 'change2'] + const action = EditorActions.hasRecordChangedSinceDraftSuccess({ + changes, + }) + const result: EditorState = editorReducer( + { ...initialEditorState, hasRecordChanged: [] }, + action + ) + + expect(result.hasRecordChanged).toEqual(changes) + }) }) describe('unknown action', () => { diff --git a/libs/feature/editor/src/lib/+state/editor.reducer.ts b/libs/feature/editor/src/lib/+state/editor.reducer.ts index 29848bb9cd..996d6f086a 100644 --- a/libs/feature/editor/src/lib/+state/editor.reducer.ts +++ b/libs/feature/editor/src/lib/+state/editor.reducer.ts @@ -24,6 +24,7 @@ export interface EditorState { changedSinceSave: boolean editorConfig: EditorConfig currentPage: number + hasRecordChanged: { user: string; date: Date } } export interface EditorPartialState { @@ -39,6 +40,7 @@ export const initialEditorState: EditorState = { changedSinceSave: false, editorConfig: DEFAULT_CONFIGURATION, currentPage: 0, + hasRecordChanged: null, } const reducer = createReducer( @@ -104,6 +106,10 @@ const reducer = createReducer( })), })), }, + })), + on(EditorActions.hasRecordChangedSinceDraftSuccess, (state, { changes }) => ({ + ...state, + hasRecordChanged: changes, })) ) 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 b82f8723f2..c17eca258e 100644 --- a/libs/feature/editor/src/lib/+state/editor.selectors.spec.ts +++ b/libs/feature/editor/src/lib/+state/editor.selectors.spec.ts @@ -21,6 +21,7 @@ describe('Editor Selectors', () => { saveError: 'something went wrong', saving: false, changedSinceSave: true, + hasRecordChanged: ['date', 'user'], }, } }) @@ -61,6 +62,11 @@ describe('Editor Selectors', () => { expect(result).toEqual(DEFAULT_CONFIGURATION) }) + it('selectHasRecordChanged() should return the current "hasRecordChanged" state', () => { + const result = EditorSelectors.selectHasRecordChanged(state) + expect(result).toEqual(['date', 'user']) + }) + describe('selectRecordFields', () => { it('should return the config and value for specified page', () => { const recordSections = EditorSelectors.selectRecordSections(state) diff --git a/libs/feature/editor/src/lib/+state/editor.selectors.ts b/libs/feature/editor/src/lib/+state/editor.selectors.ts index 886daa909a..528f5683b6 100644 --- a/libs/feature/editor/src/lib/+state/editor.selectors.ts +++ b/libs/feature/editor/src/lib/+state/editor.selectors.ts @@ -61,3 +61,8 @@ export const selectRecordSections = createSelector( })) as EditorSectionWithValues[] } ) + +export const selectHasRecordChanged = createSelector( + selectEditorState, + (state: EditorState) => state.hasRecordChanged +) diff --git a/libs/feature/editor/src/lib/services/editor.service.ts b/libs/feature/editor/src/lib/services/editor.service.ts index 64d58af9bf..9d6027ccaa 100644 --- a/libs/feature/editor/src/lib/services/editor.service.ts +++ b/libs/feature/editor/src/lib/services/editor.service.ts @@ -59,6 +59,7 @@ export class EditorService { record: CatalogRecord, recordSource: string ): Observable { + record.recordUpdated = new Date() return this.recordsRepository .saveRecordAsDraft(record, recordSource) .pipe(map(() => undefined)) @@ -70,4 +71,10 @@ export class EditorService { this.recordsRepository.clearRecordDraft(record.uniqueIdentifier) return this.recordsRepository.openRecordForEdition(record.uniqueIdentifier) } + + hasRecordChangedSinceDraft( + localRecord: CatalogRecord + ): Observable<{ user: string; date: Date }> { + return this.recordsRepository.hasRecordChangedSinceDraft(localRecord) + } } diff --git a/tools/e2e/commands.ts b/tools/e2e/commands.ts index 27024e63cb..d2a8acea66 100644 --- a/tools/e2e/commands.ts +++ b/tools/e2e/commands.ts @@ -18,6 +18,7 @@ declare namespace Cypress { clearRecordDrafts(): void editor_readFormUniqueIdentifier(): Chainable editor_wrapPreviousDraft(): void + editor_wrapFirstDraft(): void editor_publishAndReload(): void editor_findDraftInLocalStorage(): Chainable @@ -185,6 +186,18 @@ Cypress.Commands.add('editor_findDraftInLocalStorage', () => { }) }) +// this needs a recordUuid to have been wrapped +Cypress.Commands.add('editor_wrapFirstDraft', () => { + cy.get('@recordUuid').then((recordUuid) => { + cy.window() + .its('localStorage') + .invoke('getItem', `geonetwork-ui-draft-${recordUuid}`) + .then((previousDraft) => { + cy.wrap(previousDraft).as('firstDraft') + }) + }) +}) + // this needs a recordUuid to have been wrapped Cypress.Commands.add('editor_wrapPreviousDraft', () => { cy.get('@recordUuid').then((recordUuid) => { diff --git a/translations/de.json b/translations/de.json index 3bc5c2400c..615833c769 100644 --- a/translations/de.json +++ b/translations/de.json @@ -211,6 +211,7 @@ "editor.record.form.constraint.not.known": "Die Bedingungen sind unbekannt.", "editor.record.form.constraint.otherConstraints": "", "editor.record.form.constraint.securityConstraints": "", + "editor.record.form.draft.updateAlert": "", "editor.record.form.field.abstract": "Kurzbeschreibung", "editor.record.form.field.constraintsShortcuts": "", "editor.record.form.field.contacts.noContact": "", @@ -287,6 +288,9 @@ "editor.record.onlineResourceError.title": "", "editor.record.placeKeywordWithoutLabel": "", "editor.record.publish": "Diesen Datensatz veröffentlichen", + "editor.record.publish.confirmation.cancelText": "", + "editor.record.publish.confirmation.confirmText": "", + "editor.record.publish.confirmation.message": "", "editor.record.publishError.body": "Der Datensatz konnte nicht veröffentlicht werden:", "editor.record.publishError.closeMessage": "Verstanden", "editor.record.publishError.title": "Fehler beim Veröffentlichen des Datensatzes", diff --git a/translations/en.json b/translations/en.json index 0b08b8caf2..f18724e436 100644 --- a/translations/en.json +++ b/translations/en.json @@ -211,6 +211,7 @@ "editor.record.form.constraint.not.known": "The conditions are unknown.", "editor.record.form.constraint.otherConstraints": "Other constraints", "editor.record.form.constraint.securityConstraints": "Security constraints", + "editor.record.form.draft.updateAlert": "Since you created this draft, the record has been updated on { date } by { user }. Publishing your draft might erase their edits. To avoid this, you need to either cancel your changes or knowingly publish your own version.", "editor.record.form.field.abstract": "Abstract", "editor.record.form.field.constraintsShortcuts": "", "editor.record.form.field.contacts.noContact": "Please provide at least one point of contact.", @@ -287,6 +288,9 @@ "editor.record.onlineResourceError.title": "Error adding resource", "editor.record.placeKeywordWithoutLabel": "Unnamed location", "editor.record.publish": "Publish this record", + "editor.record.publish.confirmation.cancelText": "Cancel", + "editor.record.publish.confirmation.confirmText": "Publish", + "editor.record.publish.confirmation.message": "Since you created this draft, the record has been updated on { date } by { user }. Publishing your draft might erase their edits. Do you wish to proceed ?", "editor.record.publishError.body": "The record could not be published:", "editor.record.publishError.closeMessage": "Understood", "editor.record.publishError.title": "Error publishing record", diff --git a/translations/es.json b/translations/es.json index 6292a8e6d0..1bed5d65f9 100644 --- a/translations/es.json +++ b/translations/es.json @@ -211,6 +211,7 @@ "editor.record.form.constraint.not.known": "", "editor.record.form.constraint.otherConstraints": "", "editor.record.form.constraint.securityConstraints": "", + "editor.record.form.draft.updateAlert": "", "editor.record.form.field.abstract": "", "editor.record.form.field.constraintsShortcuts": "", "editor.record.form.field.contacts.noContact": "", @@ -287,6 +288,9 @@ "editor.record.onlineResourceError.title": "", "editor.record.placeKeywordWithoutLabel": "", "editor.record.publish": "", + "editor.record.publish.confirmation.cancelText": "", + "editor.record.publish.confirmation.confirmText": "", + "editor.record.publish.confirmation.message": "", "editor.record.publishError.body": "", "editor.record.publishError.closeMessage": "", "editor.record.publishError.title": "", diff --git a/translations/fr.json b/translations/fr.json index 16d398c314..264d2c0310 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -211,6 +211,7 @@ "editor.record.form.constraint.not.known": "Les conditions sont inconnues.", "editor.record.form.constraint.otherConstraints": "Autres contraintes", "editor.record.form.constraint.securityConstraints": "Contraintes de sécurité", + "editor.record.form.draft.updateAlert": "Depuis la création de ce brouillon, cette fiche a été modifiée le { date } par { user }. Publier votre version peut supprimer ses modifications. Pour éviter cela, vous pouvez annuler vos changements, ou publier votre version en connaissance de cause.", "editor.record.form.field.abstract": "Résumé", "editor.record.form.field.constraintsShortcuts": "", "editor.record.form.field.contacts.noContact": "Veuillez renseigner au moins un point de contact.", @@ -287,6 +288,9 @@ "editor.record.onlineResourceError.title": "Erreur lors de l'ajout d'une ressource", "editor.record.placeKeywordWithoutLabel": "Localisation sans nom", "editor.record.publish": "Publier cette fiche", + "editor.record.publish.confirmation.cancelText": "Annuler", + "editor.record.publish.confirmation.confirmText": "Publier", + "editor.record.publish.confirmation.message": "Depuis la création de votre brouillon, cette fiche a été modifiée le { date } par { user }. Publier votre version pourrait supprimer ses modifications. Souhaitez-vous poursuivre ?", "editor.record.publishError.body": "La fiche n'a pas pu être publiée :", "editor.record.publishError.closeMessage": "Compris", "editor.record.publishError.title": "Erreur lors de la publication", diff --git a/translations/it.json b/translations/it.json index 2256e7d942..19217bb533 100644 --- a/translations/it.json +++ b/translations/it.json @@ -211,6 +211,7 @@ "editor.record.form.constraint.not.known": "", "editor.record.form.constraint.otherConstraints": "", "editor.record.form.constraint.securityConstraints": "", + "editor.record.form.draft.updateAlert": "", "editor.record.form.field.abstract": "", "editor.record.form.field.constraintsShortcuts": "", "editor.record.form.field.contacts.noContact": "", @@ -287,6 +288,9 @@ "editor.record.onlineResourceError.title": "", "editor.record.placeKeywordWithoutLabel": "", "editor.record.publish": "", + "editor.record.publish.confirmation.cancelText": "", + "editor.record.publish.confirmation.confirmText": "", + "editor.record.publish.confirmation.message": "", "editor.record.publishError.body": "", "editor.record.publishError.closeMessage": "", "editor.record.publishError.title": "", diff --git a/translations/nl.json b/translations/nl.json index 8ce69d2469..8a459ff95a 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -211,6 +211,7 @@ "editor.record.form.constraint.not.known": "", "editor.record.form.constraint.otherConstraints": "", "editor.record.form.constraint.securityConstraints": "", + "editor.record.form.draft.updateAlert": "", "editor.record.form.field.abstract": "", "editor.record.form.field.constraintsShortcuts": "", "editor.record.form.field.contacts.noContact": "", @@ -287,6 +288,9 @@ "editor.record.onlineResourceError.title": "", "editor.record.placeKeywordWithoutLabel": "", "editor.record.publish": "", + "editor.record.publish.confirmation.cancelText": "", + "editor.record.publish.confirmation.confirmText": "", + "editor.record.publish.confirmation.message": "", "editor.record.publishError.body": "", "editor.record.publishError.closeMessage": "", "editor.record.publishError.title": "", diff --git a/translations/pt.json b/translations/pt.json index 1fd3657736..544401e616 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -211,6 +211,7 @@ "editor.record.form.constraint.not.known": "", "editor.record.form.constraint.otherConstraints": "", "editor.record.form.constraint.securityConstraints": "", + "editor.record.form.draft.updateAlert": "", "editor.record.form.field.abstract": "", "editor.record.form.field.constraintsShortcuts": "", "editor.record.form.field.contacts.noContact": "", @@ -287,6 +288,9 @@ "editor.record.onlineResourceError.title": "", "editor.record.placeKeywordWithoutLabel": "", "editor.record.publish": "", + "editor.record.publish.confirmation.cancelText": "", + "editor.record.publish.confirmation.confirmText": "", + "editor.record.publish.confirmation.message": "", "editor.record.publishError.body": "", "editor.record.publishError.closeMessage": "", "editor.record.publishError.title": "", diff --git a/translations/sk.json b/translations/sk.json index 070a188a9e..5ecf1c88d0 100644 --- a/translations/sk.json +++ b/translations/sk.json @@ -211,6 +211,7 @@ "editor.record.form.constraint.not.known": "", "editor.record.form.constraint.otherConstraints": "", "editor.record.form.constraint.securityConstraints": "", + "editor.record.form.draft.updateAlert": "", "editor.record.form.field.abstract": "", "editor.record.form.field.constraintsShortcuts": "", "editor.record.form.field.contacts.noContact": "", @@ -287,6 +288,9 @@ "editor.record.onlineResourceError.title": "", "editor.record.placeKeywordWithoutLabel": "", "editor.record.publish": "", + "editor.record.publish.confirmation.cancelText": "", + "editor.record.publish.confirmation.confirmText": "", + "editor.record.publish.confirmation.message": "", "editor.record.publishError.body": "", "editor.record.publishError.closeMessage": "", "editor.record.publishError.title": "",