Skip to content

Commit

Permalink
Merge pull request #894 from geonetwork/me-save-draft-record
Browse files Browse the repository at this point in the history
Metadata Editor / keep a draft of a record being edited
  • Loading branch information
jahow authored Jun 10, 2024
2 parents a2f3bb9 + c7c8211 commit cd525aa
Show file tree
Hide file tree
Showing 47 changed files with 1,205 additions and 250 deletions.
37 changes: 23 additions & 14 deletions apps/metadata-editor/src/app/edit-record.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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], '<xml>blabla</xml>', false])
)
}

const activatedRoute = {
Expand All @@ -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)
})

Expand All @@ -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],
'<xml>blabla</xml>',
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({
Expand Down
43 changes: 23 additions & 20 deletions apps/metadata-editor/src/app/edit-record.resolver.ts
Original file line number Diff line number Diff line change
@@ -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<CatalogRecord> {
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
})
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,33 @@
<gn-ui-button type="light">
<mat-icon class="material-symbols-outlined">undo</mat-icon>
</gn-ui-button>
<div class="grow text-center">Save status</div>
<div
class="grow flex flex-row items-center justify-center gap-1 text-[14px]"
[ngSwitch]="saveStatus$ | async"
>
<ng-container *ngSwitchCase="'draft_only'">
<mat-icon
class="material-symbols-outlined text-slate-400"
style="font-variation-settings: 'FILL' 1"
>check_circle</mat-icon
>
<span translate>editor.record.saveStatus.asDraftOnly</span>
</ng-container>
<ng-container *ngSwitchCase="'record_up_to_date'">
<mat-icon
class="material-symbols-outlined text-lime-400"
style="font-variation-settings: 'FILL' 1"
>check_circle</mat-icon
>
<span translate>editor.record.saveStatus.recordUpToDate</span>
</ng-container>
<ng-container *ngSwitchCase="'draft_changes_pending'">
<mat-icon class="material-symbols-outlined text-sky-300"
>pending</mat-icon
>
<span translate>editor.record.saveStatus.draftWithChangesPending</span>
</ng-container>
</div>
<gn-ui-button type="light">
<mat-icon class="material-symbols-outlined">help</mat-icon>
</gn-ui-button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -13,10 +21,17 @@ class MockPublishButtonComponent {}
describe('TopToolbarComponent', () => {
let component: TopToolbarComponent
let fixture: ComponentFixture<TopToolbarComponent>
let editorFacade: EditorFacadeMock

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TopToolbarComponent],
imports: [TopToolbarComponent, TranslateModule.forRoot()],
providers: [
{
provide: EditorFacade,
useClass: EditorFacadeMock,
},
],
})
.overrideComponent(TopToolbarComponent, {
add: {
Expand All @@ -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')
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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<typeof this.SaveStatus[number]> =
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) {}
}
8 changes: 6 additions & 2 deletions apps/metadata-editor/src/app/edit/edit-page.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { TranslateModule } from '@ngx-translate/core'
const getRoute = () => ({
snapshot: {
data: {
record: DATASET_RECORDS[0],
record: [DATASET_RECORDS[0], '<xml>blabla</xml>', false],
},
},
})
Expand Down Expand Up @@ -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],
'<xml>blabla</xml>',
false
)
})
})

Expand Down
9 changes: 7 additions & 2 deletions apps/metadata-editor/src/app/edit/edit-page.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
4 changes: 1 addition & 3 deletions apps/webcomponents/src/app/components/base.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,7 @@ export class BaseComponent implements OnChanges, OnInit {
uuid: string,
usages: LinkUsage[]
): Promise<DatasetDistribution | null> {
const record = await firstValueFrom(
this.recordsRepository.getByUniqueIdentifier(uuid)
)
const record = await firstValueFrom(this.recordsRepository.getRecord(uuid))
if (record?.kind !== 'dataset') {
return null
}
Expand Down
25 changes: 24 additions & 1 deletion jest.setup.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, string> = {}
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(),
})
})
Loading

0 comments on commit cd525aa

Please sign in to comment.