diff --git a/apps/datahub-e2e/src/e2e/datasetDetailPage.cy.ts b/apps/datahub-e2e/src/e2e/datasetDetailPage.cy.ts index 487be23b3d..8c0f0dda1c 100644 --- a/apps/datahub-e2e/src/e2e/datasetDetailPage.cy.ts +++ b/apps/datahub-e2e/src/e2e/datasetDetailPage.cy.ts @@ -688,3 +688,76 @@ describe('api form', () => { }) }) }) + +describe('userFeedback', () => { + describe('when not logged in', () => { + beforeEach(() => { + cy.visit('/dataset/accroche_velos') + cy.get('datahub-record-user-feedbacks').as('userFeedback') + }) + it('should sort comments', () => { + cy.get('gn-ui-user-feedback-item') + .find('[data-cy="commentText"]') + .as('commentText') + + cy.get('@commentText') + .first() + .then((div) => { + const firstCommentBeforeSort = div.text().trim() + cy.get('@userFeedback') + .find('gn-ui-dropdown-selector') + .openDropdown() + .children('button') + .eq(1) + .click() + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000) + + cy.get('gn-ui-user-feedback-item') + .find('[data-cy="commentText"]') + .first() + .then((div) => { + const firstCommentAfterSort = div.text().trim() + expect(firstCommentBeforeSort).to.not.eq(firstCommentAfterSort) + }) + }) + }) + it("shouldn't be able to comment", () => { + cy.get('datahub-record-user-feedbacks') + .find('gn-ui-text-area') + .should('not.exist') + }) + }) + + describe('when logged in', () => { + beforeEach(() => { + cy.login() + cy.visit('/dataset/accroche_velos') + }) + it('should publish a comment', () => { + cy.get('datahub-record-user-feedbacks') + .find('gn-ui-text-area') + .first() + .should('exist') + .type('Something') + + cy.get('datahub-record-user-feedbacks') + .find('gn-ui-button') + .eq(1) + .should('exist') + }) + it('should answer to a comment', () => { + cy.get('gn-ui-user-feedback-item') + .find('gn-ui-text-area') + .first() + .should('exist') + .type('Something') + + cy.get('gn-ui-user-feedback-item') + .find('gn-ui-button') + .eq(0) + .should('exist') + }) + }) +}) diff --git a/apps/datahub/src/app/app.module.ts b/apps/datahub/src/app/app.module.ts index 9c4b458339..117a2fee9f 100644 --- a/apps/datahub/src/app/app.module.ts +++ b/apps/datahub/src/app/app.module.ts @@ -69,19 +69,23 @@ import { FormsModule } from '@angular/forms' import { UiDatavizModule } from '@geonetwork-ui/ui/dataviz' import { LANGUAGES_LIST, UiCatalogModule } from '@geonetwork-ui/ui/catalog' import { + LOGIN_URL, METADATA_LANGUAGE, + provideGn4, provideRepositoryUrl, } from '@geonetwork-ui/api/repository' import { BrowserAnimationsModule } from '@angular/platform-browser/animations' -import { LOGIN_URL, provideGn4 } from '@geonetwork-ui/api/repository' import { RecordRelatedRecordsComponent } from './record/record-related-records/record-related-records.component' import { RecordMetadataComponent } from './record/record-metadata/record-metadata.component' import { RecordOtherlinksComponent } from './record/record-otherlinks/record-otherlinks.component' import { RecordDownloadsComponent } from './record/record-downloads/record-downloads.component' import { RecordApisComponent } from './record/record-apis/record-apis.component' import { MatTabsModule } from '@angular/material/tabs' +import { UiWidgetsModule } from '@geonetwork-ui/ui/widgets' +import { RecordUserFeedbacksComponent } from './record/record-user-feedbacks/record-user-feedbacks.component' export const metaReducers: MetaReducer[] = !environment.production ? [] : [] + // https://github.com/nrwl/nx/issues/191 @NgModule({ declarations: [ @@ -100,6 +104,7 @@ export const metaReducers: MetaReducer[] = !environment.production ? [] : [] KeyFiguresComponent, NavigationMenuComponent, RecordRelatedRecordsComponent, + RecordUserFeedbacksComponent, RecordMetadataComponent, RecordOtherlinksComponent, RecordDownloadsComponent, @@ -144,6 +149,7 @@ export const metaReducers: MetaReducer[] = !environment.production ? [] : [] UiInputsModule, UiCatalogModule, MatTabsModule, + UiWidgetsModule, ], providers: [ importProvidersFrom(FeatureAuthModule), diff --git a/apps/datahub/src/app/record/record-metadata/record-metadata.component.html b/apps/datahub/src/app/record/record-metadata/record-metadata.component.html index 51ecee9de2..a339208c56 100644 --- a/apps/datahub/src/app/record/record-metadata/record-metadata.component.html +++ b/apps/datahub/src/app/record/record-metadata/record-metadata.component.html @@ -1,24 +1,24 @@ -
+
@@ -32,7 +32,7 @@
@@ -40,7 +40,7 @@ record.metadata.quality

@@ -138,7 +138,7 @@
@@ -148,14 +148,22 @@
+
+
+ +
+
mapLinks?.length > 0 || geoDataLinks?.length > 0 ) ) + displayData$ = combineLatest([ - this.facade.dataLinks$, - this.facade.geoDataLinks$, + this.metadataViewFacade.dataLinks$, + this.metadataViewFacade.geoDataLinks$, ]).pipe( map( ([dataLinks, geoDataLinks]) => dataLinks?.length > 0 || geoDataLinks?.length > 0 ) ) - displayDownload$ = this.facade.downloadLinks$.pipe( + + displayDownload$ = this.metadataViewFacade.downloadLinks$.pipe( + map((links) => links?.length > 0) + ) + displayApi$ = this.metadataViewFacade.apiLinks$.pipe( map((links) => links?.length > 0) ) - displayApi$ = this.facade.apiLinks$.pipe(map((links) => links?.length > 0)) - displayOtherLinks = this.facade.otherLinks$.pipe( + + displayOtherLinks = this.metadataViewFacade.otherLinks$.pipe( map((links) => links?.length > 0) ) - displayRelated$ = this.facade.related$.pipe( + displayRelated$ = this.metadataViewFacade.related$.pipe( map((records) => records?.length > 0) ) - sourceLabel$ = this.facade.metadata$.pipe( + organisationName$ = this.metadataViewFacade.metadata$.pipe( + map((record) => record?.ownerOrganization?.name), + filter(Boolean) + ) + + metadataUuid$ = this.metadataViewFacade.metadata$.pipe( + map((record) => record?.uniqueIdentifier), + filter(Boolean) + ) + + sourceLabel$ = this.metadataViewFacade.metadata$.pipe( map((record) => record?.extras?.catalogUuid as string), filter((uuid) => !!uuid), mergeMap((uuid) => this.sourceService.getSourceLabel(uuid)) ) errorTypes = ErrorType + selectedTabIndex$ = new BehaviorSubject(0) - thumbnailUrl$ = this.facade.metadata$.pipe( + thumbnailUrl$ = this.metadataViewFacade.metadata$.pipe( map((metadata) => { // in order to differentiate between metadata not loaded yet // and url not defined @@ -74,7 +90,7 @@ export class RecordMetadataComponent { showOverlay = true constructor( - public facade: MdViewFacade, + public metadataViewFacade: MdViewFacade, private searchService: SearchService, private sourceService: SourcesService, private orgsService: OrganizationsServiceInterface @@ -90,6 +106,7 @@ export class RecordMetadataComponent { onInfoKeywordClick(keyword: Keyword) { this.searchService.updateFilters({ any: keyword.label }) } + onOrganizationClick(org: Organization) { this.orgsService .getFiltersForOrgs([org]) diff --git a/apps/datahub/src/app/record/record-user-feedbacks/record-user-feedbacks.component.css b/apps/datahub/src/app/record/record-user-feedbacks/record-user-feedbacks.component.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/datahub/src/app/record/record-user-feedbacks/record-user-feedbacks.component.html b/apps/datahub/src/app/record/record-user-feedbacks/record-user-feedbacks.component.html new file mode 100644 index 0000000000..29a7f9c157 --- /dev/null +++ b/apps/datahub/src/app/record/record-user-feedbacks/record-user-feedbacks.component.html @@ -0,0 +1,101 @@ +
+

+ record.metadata.userFeedbacks +

+
+ + +
+
+ + +
+ +
+
+ + + +
+
+ edit + +
+ + + send + + +
+ +
+
+
+
+
+
+
+ +
+ edit + record.metadata.userFeedbacks.anonymousUser + + account_box + button.login + +
+
+ +
+
+ +
+
+
diff --git a/apps/datahub/src/app/record/record-user-feedbacks/record-user-feedbacks.component.spec.ts b/apps/datahub/src/app/record/record-user-feedbacks/record-user-feedbacks.component.spec.ts new file mode 100644 index 0000000000..2ccbb630f3 --- /dev/null +++ b/apps/datahub/src/app/record/record-user-feedbacks/record-user-feedbacks.component.spec.ts @@ -0,0 +1,160 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing' +import { RecordUserFeedbacksComponent } from './record-user-feedbacks.component' +import { TranslateModule } from '@ngx-translate/core' +import { MdViewFacade } from '@geonetwork-ui/feature/record' +import { BehaviorSubject, of, Subject } from 'rxjs' +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + NO_ERRORS_SCHEMA, +} from '@angular/core' +import { PlatformServiceInterface } from '@geonetwork-ui/common/domain/platform.service.interface' +import { + DATASET_RECORDS, + SOME_USER_FEEDBACKS, + USER_FIXTURE, +} from '@geonetwork-ui/common/fixtures' +import { + UserFeedback, + UserFeedbackViewModel, +} from '@geonetwork-ui/common/domain/model/record' +import { Gn4PlatformMapper } from '@geonetwork-ui/api/repository' + +describe('RelatedRecordsComponent', () => { + const allUserFeedbacks = SOME_USER_FEEDBACKS + let mockDestroy$: Subject + + const activeUser = USER_FIXTURE() + + const mdViewFacadeMock: Partial = { + isAllUserFeedbackLoading$: new BehaviorSubject(false), + isAddUserFeedbackLoading$: new BehaviorSubject(false), + loadUserFeedbacks: jest.fn(), + userFeedbacks$: of(allUserFeedbacks), + addUserFeedback: jest.fn(), + } + + const gn4PlatformMapperMock: Partial = { + createUserFeedbackViewModel: (baseUserFeedback) => { + return Promise.resolve({ + ...baseUserFeedback, + avatarUrl: 'someAvatarUrl', + } as UserFeedbackViewModel) + }, + } + + const changeDetectorRefMock: Partial = { + markForCheck: jest.fn(), + } + + const platformServiceInterfaceMock: Partial = { + getUserFeedbacks: jest.fn(), + getMe: jest.fn(() => new BehaviorSubject(activeUser)), + } + + let component: RecordUserFeedbacksComponent + let fixture: ComponentFixture + + beforeEach(async () => { + mockDestroy$ = new Subject() + + await TestBed.configureTestingModule({ + declarations: [RecordUserFeedbacksComponent], + imports: [TranslateModule.forRoot()], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { + provide: MdViewFacade, + useValue: mdViewFacadeMock, + }, + { + provide: ChangeDetectorRef, + useValue: changeDetectorRefMock, + }, + { + provide: PlatformServiceInterface, + useValue: platformServiceInterfaceMock, + }, + { + provide: Gn4PlatformMapper, + useValue: gn4PlatformMapperMock, + }, + ], + }) + .overrideComponent(RecordUserFeedbacksComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + }, + }) + .compileComponents() + + fixture = TestBed.createComponent(RecordUserFeedbacksComponent) + component = fixture.componentInstance + + component.destroy$ = mockDestroy$ + component.metadataUuid = DATASET_RECORDS[0].uniqueIdentifier + + fixture.detectChanges() + }) + + afterEach(() => { + mockDestroy$.next() + mockDestroy$.complete() + }) + + it('should create', () => { + fixture.detectChanges() + expect(component).toBeTruthy() + }) + + describe('ngOnInit()', () => { + it('should load user feedbacks', () => { + component.ngOnInit() + expect(mdViewFacadeMock.loadUserFeedbacks).toHaveBeenCalledWith( + DATASET_RECORDS[0].uniqueIdentifier + ) + }) + it('should set active user', fakeAsync(() => { + component.ngOnInit() + tick() + expect(component.activeUser).toEqual(USER_FIXTURE()) + })) + it('should fetch user feedbacks and sort them correctly', async () => { + component.ngOnInit() + await fixture.whenStable() + expect(component.userFeedbacksParents.length).toBe(4) + expect( + component.userFeedBacksAnswers.get(SOME_USER_FEEDBACKS[0].uuid).length + ).toBe(2) + }) + }) + + describe('publishNewComment()', () => { + it('should publish the new userFeedback', () => { + const expectedNewUserFeedback: UserFeedback = { + uuid: undefined, + comment: 'TEST', + metadataUUID: 'accroche_velos', + parentUuid: null, + published: true, + date: expect.any(Date), + authorUserId: activeUser.id, + authorEmail: activeUser.email, + authorName: `${activeUser.name} ${activeUser.surname}`, + } + + component.newComment = 'TEST' + component.metadataUuid = 'accroche_velos' + fixture.detectChanges() + component.publishNewComment() + expect(mdViewFacadeMock.addUserFeedback).toHaveBeenCalledWith( + expectedNewUserFeedback + ) + }) + }) +}) diff --git a/apps/datahub/src/app/record/record-user-feedbacks/record-user-feedbacks.component.ts b/apps/datahub/src/app/record/record-user-feedbacks/record-user-feedbacks.component.ts new file mode 100644 index 0000000000..7561f56215 --- /dev/null +++ b/apps/datahub/src/app/record/record-user-feedbacks/record-user-feedbacks.component.ts @@ -0,0 +1,229 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core' +import { filter, switchMap, takeUntil } from 'rxjs/operators' +import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs' +import { + UserFeedback, + UserFeedbackViewModel, +} from '@geonetwork-ui/common/domain/model/record' +import { PlatformServiceInterface } from '@geonetwork-ui/common/domain/platform.service.interface' +import { UserModel } from '@geonetwork-ui/common/domain/model/user' +import { DropdownChoice } from '@geonetwork-ui/ui/inputs' +import { MdViewFacade } from '@geonetwork-ui/feature/record' +import { TranslateService } from '@ngx-translate/core' +import { AuthService, Gn4PlatformMapper } from '@geonetwork-ui/api/repository' +import { UserApiModel } from '@geonetwork-ui/data-access/gn4' + +type UserFeedbackSortingFunction = ( + userFeedbackA: UserFeedback, + userFeedbackB: UserFeedback +) => number + +@Component({ + selector: 'datahub-record-user-feedbacks', + templateUrl: './record-user-feedbacks.component.html', + styleUrls: ['./record-user-feedbacks.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RecordUserFeedbacksComponent implements OnInit, OnDestroy { + @Input() organisationName$: Observable + @Input() metadataUuid: string + + destroy$ = new Subject() + + userFeedbacksParents: UserFeedbackViewModel[] = [] + userFeedBacksAnswers: Map = new Map() + + newComment = '' + + isNewCommentEmpty = true + + activeUser$: Observable + activeUser?: UserModel + isActiveUserMetadaEditor = false + + loginUrl = this.authService.loginUrl + + sortingStrategyList: Array = [ + { + value: this.sortByDateFromNewestToOldest, + label: this.translate.instant( + 'record.metadata.userFeedbacks.sortSelector.choices.newestFirst' + ), + }, + { + value: this.sortByDateFromOldestToNewest, + label: this.translate.instant( + 'record.metadata.userFeedbacks.sortSelector.choices.oldestFirst' + ), + }, + ] + + selectedSortingStrategy$ = new BehaviorSubject( + this.sortByDateFromNewestToOldest + ) + + isAllUserFeedbackLoading = false + isAddUserFeedbackLoading = false + + constructor( + private readonly translate: TranslateService, + private readonly authService: AuthService, + private readonly metadataViewFacade: MdViewFacade, + private readonly cdr: ChangeDetectorRef, + private readonly mapper: Gn4PlatformMapper, + private readonly platformServiceInterface: PlatformServiceInterface + ) { + this.activeUser$ = this.platformServiceInterface.getMe() + } + + ngOnInit(): void { + this.metadataViewFacade.isAllUserFeedbackLoading$ + .pipe(takeUntil(this.destroy$)) + .subscribe((isLoading) => (this.isAllUserFeedbackLoading = isLoading)) + + this.metadataViewFacade.isAddUserFeedbackLoading$ + .pipe(takeUntil(this.destroy$)) + .subscribe((isLoading) => (this.isAddUserFeedbackLoading = isLoading)) + + this.metadataViewFacade.loadUserFeedbacks(this.metadataUuid) + + this.activeUser$.pipe(takeUntil(this.destroy$)).subscribe((user) => { + this.activeUser = user + this.isActiveUserMetadaEditor = [ + 'Administrator', + 'UserAdmin', + 'Reviewer', + 'Editor', + ].includes(user?.profile) + }) + + combineLatest([ + this.metadataViewFacade.userFeedbacks$, + this.selectedSortingStrategy$, + this.activeUser$, + ]) + .pipe( + filter(([userFeedbacks]) => Boolean(userFeedbacks)), + takeUntil(this.destroy$), + switchMap( + async ([userFeedbacks, selectedSortingStrategy, activeUser]) => { + this.activeUser = activeUser + + const userFeedbacksParents = userFeedbacks + .filter((feedback) => !feedback.parentUuid) + .sort(selectedSortingStrategy) + + const userFeedbacksAnswers = userFeedbacks + .filter((feedback) => feedback.parentUuid) + .sort(this.sortByDateFromOldestToNewest) + + const userFeedbacksParentsViewModels = await Promise.all( + userFeedbacksParents.map((feedback) => + this.mapper.createUserFeedbackViewModel(feedback) + ) + ) + + const userFeedbacksAnswersViewModels = await Promise.all( + userFeedbacksAnswers.map((feedback) => + this.mapper.createUserFeedbackViewModel(feedback) + ) + ) + + const userFeedBacksAnswersMap = new Map() + userFeedbacksAnswersViewModels.forEach( + (userFeedbackAnswerViewModel) => { + const parentUuid = userFeedbackAnswerViewModel.parentUuid + if (userFeedBacksAnswersMap.has(parentUuid)) { + userFeedBacksAnswersMap + .get(parentUuid) + .push(userFeedbackAnswerViewModel) + } else { + userFeedBacksAnswersMap.set(parentUuid, [ + userFeedbackAnswerViewModel, + ]) + } + } + ) + + return { + parentsViewModels: userFeedbacksParentsViewModels, + answersMap: userFeedBacksAnswersMap, + } + } + ) + ) + .subscribe({ + next: ({ parentsViewModels, answersMap }) => { + this.userFeedbacksParents = parentsViewModels + this.userFeedBacksAnswers = answersMap + this.cdr.markForCheck() + }, + error: (err) => { + console.error('Error processing feedback', err) + }, + }) + } + + onNewCommentValueChange() { + this.isNewCommentEmpty = this.newComment.length === 0 + } + + onNewUserFeedbackAnswer(newUserFeedback: UserFeedbackViewModel) { + const userFeedBack = this.mapper.userFeedbacksFromApi(newUserFeedback) + this.newUserFeedback(userFeedBack) + } + + publishNewComment() { + if (this.newComment.trim() === '') return + + const newUserFeedback: UserFeedback = { + uuid: undefined, + comment: this.newComment, + metadataUUID: this.metadataUuid, + parentUuid: null, + published: true, + date: new Date(), + authorUserId: this.activeUser?.id, + authorEmail: this.activeUser?.email, + authorName: `${this.activeUser?.name} ${this.activeUser?.surname}`, + } + + this.newUserFeedback(newUserFeedback) + this.newComment = '' + this.onNewCommentValueChange() + } + + changeSort(selectedSortingStrategy: UserFeedbackSortingFunction) { + this.selectedSortingStrategy$.next(selectedSortingStrategy) + } + + private newUserFeedback(newUserFeedback: UserFeedback) { + this.metadataViewFacade.addUserFeedback(newUserFeedback) + } + + private sortByDateFromNewestToOldest( + userFeedbackA: UserFeedback, + userFeedbackB: UserFeedback + ): number { + return userFeedbackB.date.getTime() - userFeedbackA.date.getTime() + } + + private sortByDateFromOldestToNewest( + userFeedbackA: UserFeedback, + userFeedbackB: UserFeedback + ): number { + return userFeedbackA.date.getTime() - userFeedbackB.date.getTime() + } + + ngOnDestroy(): void { + this.destroy$.next() + this.destroy$.complete() + } +} diff --git a/libs/api/repository/src/lib/gn4/auth/avatar.service.interface.ts b/libs/api/repository/src/lib/gn4/auth/avatar.service.interface.ts index 2ef85b8dc6..fefe043c16 100644 --- a/libs/api/repository/src/lib/gn4/auth/avatar.service.interface.ts +++ b/libs/api/repository/src/lib/gn4/auth/avatar.service.interface.ts @@ -3,4 +3,5 @@ import { Observable } from 'rxjs' export abstract class AvatarServiceInterface { public abstract getPlaceholder(): Observable public abstract getProfileIcon(...args): Observable + public abstract getProfileIconUrl(userId: string): Promise } diff --git a/libs/api/repository/src/lib/gn4/auth/gravatar.service.spec.ts b/libs/api/repository/src/lib/gn4/auth/gravatar.service.spec.ts index 6f31600dd7..b3acd9c750 100644 --- a/libs/api/repository/src/lib/gn4/auth/gravatar.service.spec.ts +++ b/libs/api/repository/src/lib/gn4/auth/gravatar.service.spec.ts @@ -47,4 +47,15 @@ describe('GravatarService', () => { expect(placeholder).toEqual('https://www.gravatar.com/avatar/?d=mp') }) }) + describe('#getProfileIconUrl', () => { + it('returns url with identicon value, without hash', async () => { + const placeholder = await service.getProfileIconUrl('12345') + expect(placeholder).toEqual('https://www.gravatar.com/avatar/12345?d=404') + }) + it('returns placeholder to be mp if no identicon value', async () => { + settingsService.identicon$.next('') + const placeholder = await service.getProfileIconUrl('12345') + expect(placeholder).toEqual('https://www.gravatar.com/avatar/12345?d=mp') + }) + }) }) diff --git a/libs/api/repository/src/lib/gn4/auth/gravatar.service.ts b/libs/api/repository/src/lib/gn4/auth/gravatar.service.ts index ce02bddb68..fcd615547b 100644 --- a/libs/api/repository/src/lib/gn4/auth/gravatar.service.ts +++ b/libs/api/repository/src/lib/gn4/auth/gravatar.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core' import { AvatarServiceInterface } from './avatar.service.interface' import { Gn4SettingsService } from '../settings/gn4-settings.service' import { map } from 'rxjs/operators' -import { Observable } from 'rxjs' +import { firstValueFrom, Observable } from 'rxjs' @Injectable({ providedIn: 'root', @@ -14,6 +14,7 @@ export class GravatarService implements AvatarServiceInterface { private readonly identicon$ = this.gn4SettingsService.identicon$.pipe( map((identicon) => identicon?.replace('gravatar:', '')) ) + constructor(private gn4SettingsService: Gn4SettingsService) {} getPlaceholder(): Observable { @@ -26,4 +27,14 @@ export class GravatarService implements AvatarServiceInterface { map((identicon) => `${this.GRAVATAR_URL}${hash}?d=${identicon}`) ) } + + async getProfileIconUrl(userId: string) { + let iconUrl = '' + try { + iconUrl = await firstValueFrom(this.getProfileIcon(userId)) + } catch (error) { + return '' + } + return iconUrl + } } diff --git a/libs/api/repository/src/lib/gn4/platform/gn4-platform.mapper.ts b/libs/api/repository/src/lib/gn4/platform/gn4-platform.mapper.ts index fdd0682d23..db5a54dfa1 100644 --- a/libs/api/repository/src/lib/gn4/platform/gn4-platform.mapper.ts +++ b/libs/api/repository/src/lib/gn4/platform/gn4-platform.mapper.ts @@ -1,17 +1,23 @@ import { MeResponseApiModel, UserApiModel, + UserFeedbackDTOApiModel, } from '@geonetwork-ui/data-access/gn4' import { UserModel } from '@geonetwork-ui/common/domain/model/user/user.model' import { Injectable } from '@angular/core' -import { AvatarServiceInterface } from '../auth/avatar.service.interface' +import { AvatarServiceInterface } from '../auth' import { map } from 'rxjs/operators' import { Observable, of } from 'rxjs' import { ThesaurusModel } from '@geonetwork-ui/common/domain/model/thesaurus/thesaurus.model' +import { + UserFeedback, + UserFeedbackViewModel, +} from '@geonetwork-ui/common/domain/model/record' @Injectable() export class Gn4PlatformMapper { constructor(private avatarService: AvatarServiceInterface) {} + userFromMeApi(apiUser: MeResponseApiModel): Observable { if (!apiUser) return of(null) const { @@ -28,6 +34,7 @@ export class Gn4PlatformMapper { .getProfileIcon(hash) .pipe(map((profileIcon) => ({ ...user, profileIcon } as UserModel))) } + userFromApi(apiUser: UserApiModel): UserModel { if (!apiUser) return null const { @@ -65,4 +72,47 @@ export class Gn4PlatformMapper { } }) } + + userFeedbacksFromApi(userFeedback: any): UserFeedback { + return { + uuid: userFeedback.uuid, + metadataUUID: userFeedback.metadataUUID, + comment: userFeedback.comment, + authorUserId: userFeedback.authorUserId.toString(), + authorName: userFeedback.authorName, + authorEmail: userFeedback.authorEmail, + published: userFeedback.published, + parentUuid: userFeedback.parentUuid ?? undefined, + date: new Date(userFeedback.date), + } + } + + userFeedbacksToApi( + userFeedback: UserFeedback + ): Partial { + return { + uuid: userFeedback.uuid, + metadataUUID: userFeedback.metadataUUID, + comment: userFeedback.comment, + authorUserId: Number.parseInt(userFeedback.authorUserId), + authorName: userFeedback.authorName, + authorEmail: userFeedback.authorEmail, + published: userFeedback.published, + parentUuid: userFeedback.parentUuid, + date: userFeedback.date.getTime().toString(), + } + } + + async createUserFeedbackViewModel( + baseUserFeedback: UserFeedback + ): Promise { + const userAvatarUrl = await this.avatarService.getProfileIconUrl( + baseUserFeedback.authorUserId?.toString() + ) + + return { + ...baseUserFeedback, + avatarUrl: userAvatarUrl, + } + } } diff --git a/libs/api/repository/src/lib/gn4/platform/gn4-platform.service.spec.ts b/libs/api/repository/src/lib/gn4/platform/gn4-platform.service.spec.ts index beb6438345..e062a7202a 100644 --- a/libs/api/repository/src/lib/gn4/platform/gn4-platform.service.spec.ts +++ b/libs/api/repository/src/lib/gn4/platform/gn4-platform.service.spec.ts @@ -3,14 +3,20 @@ import { RegistriesApiService, SiteApiService, ToolsApiService, + UserfeedbackApiService, + UserFeedbackDTOApiModel, UsersApiService, } from '@geonetwork-ui/data-access/gn4' import { TestBed } from '@angular/core/testing' import { Gn4PlatformService } from './gn4-platform.service' -import { firstValueFrom, lastValueFrom, of, Subject } from 'rxjs' +import { firstValueFrom, lastValueFrom, of, Subject, throwError } from 'rxjs' import { AvatarServiceInterface } from '../auth/avatar.service.interface' import { Gn4PlatformMapper } from './gn4-platform.mapper' import { LangService } from '@geonetwork-ui/util/i18n' +import { + A_USER_FEEDBACK, + SOME_USER_FEEDBACKS, +} from '@geonetwork-ui/common/fixtures' let geonetworkVersion: string @@ -122,11 +128,18 @@ class LangServiceMock { iso3 = 'fre' } +class UserfeedbackApiServiceMock { + getUserComments = jest.fn(() => of(SOME_USER_FEEDBACKS)) + + newUserFeedback = jest.fn(() => of(undefined)) +} + describe('Gn4PlatformService', () => { let service: Gn4PlatformService let meApiService: MeApiService let toolsApiService: ToolsApiService let registriesApiService: RegistriesApiService + let userFeedbackApiService: UserfeedbackApiServiceMock beforeEach(() => { TestBed.configureTestingModule({ @@ -161,12 +174,17 @@ describe('Gn4PlatformService', () => { provide: LangService, useClass: LangServiceMock, }, + { + provide: UserfeedbackApiService, + useClass: UserfeedbackApiServiceMock, + }, ], }) service = TestBed.inject(Gn4PlatformService) meApiService = TestBed.inject(MeApiService) toolsApiService = TestBed.inject(ToolsApiService) registriesApiService = TestBed.inject(RegistriesApiService) + userFeedbackApiService = TestBed.inject(UserfeedbackApiService as any) }) it('creates', () => { @@ -354,5 +372,60 @@ describe('Gn4PlatformService', () => { ]) }) }) + describe('getUserFeedbacks', () => { + it('should call getUserComments with correct UUID and map results', (done) => { + const mockUuid = '1234' + const mockFeedbacks = SOME_USER_FEEDBACKS + + service.getUserFeedbacks(mockUuid).subscribe({ + next: (results) => { + expect(results).toEqual(mockFeedbacks) + expect(userFeedbackApiService.getUserComments).toHaveBeenCalledWith( + mockUuid + ) + done() + }, + error: done, + }) + }) + + it('should handle errors', (done) => { + const mockUuid = '1234' + const errorResponse = new Error('Failed to fetch') + userFeedbackApiService.getUserComments.mockReturnValue( + throwError(() => errorResponse) + ) + + service.getUserFeedbacks(mockUuid).subscribe({ + next: () => { + done('Expected error, but got success') + }, + error: (error) => { + expect(error).toBe(errorResponse) + done() + }, + }) + }) + }) + + describe('postUserFeedbacks', () => { + it('should process and post user feedbacks correctly', (done) => { + const expected: UserFeedbackDTOApiModel = { + ...A_USER_FEEDBACK, + authorUserId: expect.any(Number), + date: expect.any(String), + } + + service.postUserFeedbacks(A_USER_FEEDBACK).subscribe({ + next: () => { + expect(userFeedbackApiService.newUserFeedback).toHaveBeenCalledWith( + expected + ) + done() + }, + error: done, + }) + }) + }) }) }) diff --git a/libs/api/repository/src/lib/gn4/platform/gn4-platform.service.ts b/libs/api/repository/src/lib/gn4/platform/gn4-platform.service.ts index b7dff952ba..4c71fb3691 100644 --- a/libs/api/repository/src/lib/gn4/platform/gn4-platform.service.ts +++ b/libs/api/repository/src/lib/gn4/platform/gn4-platform.service.ts @@ -6,11 +6,15 @@ import { RegistriesApiService, SiteApiService, ToolsApiService, + UserfeedbackApiService, UsersApiService, } from '@geonetwork-ui/data-access/gn4' import { PlatformServiceInterface } from '@geonetwork-ui/common/domain/platform.service.interface' import { UserModel } from '@geonetwork-ui/common/domain/model/user/user.model' -import { Organization } from '@geonetwork-ui/common/domain/model/record' +import { + Organization, + UserFeedback, +} from '@geonetwork-ui/common/domain/model/record' import { Gn4PlatformMapper } from './gn4-platform.mapper' import { ltr } from 'semver' import { ThesaurusModel } from '@geonetwork-ui/common/domain/model/thesaurus/thesaurus.model' @@ -21,9 +25,9 @@ const minApiVersion = '4.2.2' @Injectable() export class Gn4PlatformService implements PlatformServiceInterface { private readonly type = 'GeoNetwork' - private me$: Observable - private users$: Observable - private isAnonymous$: Observable + private readonly me$: Observable + private readonly users$: Observable + private readonly isUserAnonymous$: Observable private keyTranslations$ = this.toolsApiService .getTranslationsPackage1('gnui') @@ -65,13 +69,18 @@ export class Gn4PlatformService implements PlatformServiceInterface { private mapper: Gn4PlatformMapper, private toolsApiService: ToolsApiService, private registriesApiService: RegistriesApiService, - private langService: LangService + private langService: LangService, + private userfeedbackApiService: UserfeedbackApiService ) { this.me$ = this.meApi.getMe().pipe( switchMap((apiUser) => this.mapper.userFromMeApi(apiUser)), shareReplay({ bufferSize: 1, refCount: true }) ) - this.isAnonymous$ = this.me$.pipe(map((user) => !user || !('id' in user))) + + this.isUserAnonymous$ = this.me$.pipe( + map((user) => !user || !('id' in user)) + ) + this.users$ = this.usersApi.getUsers().pipe( map((users) => users.map((user) => this.mapper.userFromApi(user))), shareReplay() @@ -91,7 +100,7 @@ export class Gn4PlatformService implements PlatformServiceInterface { } isAnonymous(): Observable { - return this.isAnonymous$ + return this.isUserAnonymous$ } getOrganizations(): Observable { @@ -148,6 +157,24 @@ export class Gn4PlatformService implements PlatformServiceInterface { ), shareReplay(1) ) + return this.thesauri[uri] } + + getUserFeedbacks(uuid: string): Observable { + return this.userfeedbackApiService + .getUserComments(uuid) + .pipe( + map((userFeedbacks) => + userFeedbacks.map(this.mapper.userFeedbacksFromApi) + ) + ) + } + + postUserFeedbacks(userFeedback: UserFeedback): Observable { + const mappedUserFeedBack = this.mapper.userFeedbacksToApi(userFeedback) + return this.userfeedbackApiService + .newUserFeedback(mappedUserFeedBack) + .pipe(map(() => undefined)) + } } diff --git a/libs/common/domain/src/lib/model/record/index.ts b/libs/common/domain/src/lib/model/record/index.ts index f312e98382..59c495239e 100644 --- a/libs/common/domain/src/lib/model/record/index.ts +++ b/libs/common/domain/src/lib/model/record/index.ts @@ -1,3 +1,4 @@ export * from './contact.model' export * from './organization.model' export * from './metadata.model' +export * from './user-feedbacks.model' diff --git a/libs/common/domain/src/lib/model/record/user-feedbacks.model.ts b/libs/common/domain/src/lib/model/record/user-feedbacks.model.ts new file mode 100644 index 0000000000..59008ea9ca --- /dev/null +++ b/libs/common/domain/src/lib/model/record/user-feedbacks.model.ts @@ -0,0 +1,15 @@ +export interface UserFeedback { + uuid: string + comment: string + metadataUUID: string + authorUserId: string + authorName: string + authorEmail: string + published: boolean + parentUuid?: string + date: Date +} + +export interface UserFeedbackViewModel extends UserFeedback { + avatarUrl: string +} diff --git a/libs/common/domain/src/lib/platform.service.interface.ts b/libs/common/domain/src/lib/platform.service.interface.ts index 1f6acc2745..545735a6f4 100644 --- a/libs/common/domain/src/lib/platform.service.interface.ts +++ b/libs/common/domain/src/lib/platform.service.interface.ts @@ -2,6 +2,7 @@ import type { Observable } from 'rxjs' import type { UserModel } from './model/user/user.model' import type { Organization } from './model/record/organization.model' import type { ThesaurusModel } from './model/thesaurus/' +import { UserFeedback } from './model/record' export abstract class PlatformServiceInterface { abstract getType(): string @@ -16,4 +17,6 @@ export abstract class PlatformServiceInterface { abstract getOrganizations(): Observable abstract translateKey(key: string): Observable abstract getThesaurusByUri(uri: string): Observable + abstract getUserFeedbacks(recordUuid: string): Observable + abstract postUserFeedbacks(recordUuid: UserFeedback): Observable } diff --git a/libs/common/fixtures/src/index.ts b/libs/common/fixtures/src/index.ts index 293c434ea3..850a472de6 100644 --- a/libs/common/fixtures/src/index.ts +++ b/libs/common/fixtures/src/index.ts @@ -1,11 +1,13 @@ +export * from './lib/elasticsearch' +export * from './lib/gn4' +export * from './lib/search' + export * from './lib/geojson.fixtures' +export * from './lib/link.fixtures' export * from './lib/ol-feature.fixture' +export * from './lib/organisations.fixture' export * from './lib/record-link.fixtures' -export * from './lib/link.fixtures' export * from './lib/records.fixtures' -export * from './lib/organisations.fixture' -export * from './lib/elasticsearch' -export * from './lib/search' -export * from './lib/user.fixtures' export * from './lib/repository.fixtures' -export * from './lib/gn4' +export * from './lib/user.fixtures' +export * from './lib/user-feedbacks.fixtures' diff --git a/libs/common/fixtures/src/lib/records.fixtures.ts b/libs/common/fixtures/src/lib/records.fixtures.ts index 8def498eab..9555fda043 100644 --- a/libs/common/fixtures/src/lib/records.fixtures.ts +++ b/libs/common/fixtures/src/lib/records.fixtures.ts @@ -1,5 +1,5 @@ import { DatasetRecord } from '@geonetwork-ui/common/domain/model/record' -import { deepFreeze } from './utils/freeze' +import { deepFreeze } from './utils' export const DATASET_RECORDS: DatasetRecord[] = deepFreeze([ { diff --git a/libs/common/fixtures/src/lib/user-feedbacks.fixtures.ts b/libs/common/fixtures/src/lib/user-feedbacks.fixtures.ts new file mode 100644 index 0000000000..f4bcc11413 --- /dev/null +++ b/libs/common/fixtures/src/lib/user-feedbacks.fixtures.ts @@ -0,0 +1,83 @@ +import { UserFeedback } from '@geonetwork-ui/common/domain/model/record' +import { deepFreeze } from './utils' + +export const SOME_USER_FEEDBACKS: UserFeedback[] = deepFreeze([ + { + uuid: '4ad03fb7-1728-424c-bdaa-aedd531b07a8', + comment: 'A nice comment.', + metadataUUID: 'my-dataset-001', + authorUserId: '46798', + authorName: 'Arnaud De Maison', + authorEmail: 'a.demaison@geo2france.fr', + published: true, + parentUuid: undefined, + date: new Date('2023-01-01T08:00:00Z'), + }, + { + uuid: '52cbd0f1-9cb9-4409-8e85-bc608f049af4', + comment: 'A very nice comment that is a reply.', + metadataUUID: 'my-dataset-001', + authorUserId: '46798', + authorName: 'Arnaud De Maison', + authorEmail: 'a.demaison@geo2france.fr', + published: true, + parentUuid: '4ad03fb7-1728-424c-bdaa-aedd531b07a8', + date: new Date('2023-01-01T09:00:00Z'), + }, + { + uuid: 'b48f62ec-b5e6-4d27-a396-2c2b44f6dcb5', + comment: 'Another nice comment.', + metadataUUID: 'my-dataset-001', + authorUserId: '46798', + authorName: 'Arnaud De Maison', + authorEmail: 'a.demaison@geo2france.fr', + published: true, + parentUuid: undefined, + date: new Date('2023-01-01T10:00:00Z'), + }, + { + uuid: '1f12a3be-fc8a-4e83-968f-9b88ffbcab02', + comment: 'Another very nice reply.', + metadataUUID: 'my-dataset-001', + authorUserId: '46798', + authorName: 'Arnaud De Maison', + authorEmail: 'a.demaison@geo2france.fr', + published: true, + parentUuid: '4ad03fb7-1728-424c-bdaa-aedd531b07a8', + date: new Date('2023-01-01T11:00:00Z'), + }, + { + uuid: 'f8dd778d-e93c-4b3c-ba9b-9574be070f46', + comment: 'Another nice comment.', + metadataUUID: 'my-dataset-001', + authorUserId: '46798', + authorName: 'Arnaud De Maison', + authorEmail: 'a.demaison@geo2france.fr', + published: true, + parentUuid: undefined, + date: new Date('2023-01-01T10:00:00Z'), + }, + { + uuid: 'df3b8872-61d1-4ae9-8822-bb070b94d7d1', + comment: 'Another nice comment.', + metadataUUID: 'my-dataset-001', + authorUserId: '46798', + authorName: 'Arnaud De Maison', + authorEmail: 'a.demaison@geo2france.fr', + published: true, + parentUuid: undefined, + date: new Date('2023-01-01T11:00:00Z'), + }, +]) + +export const A_USER_FEEDBACK: UserFeedback = deepFreeze({ + uuid: '4ad03fb7-1728-424c-bdaa-aedd531b07a8', + comment: 'A nice comment.', + metadataUUID: 'my-dataset-001', + authorUserId: '46798', + authorName: 'Arnaud De Maison', + authorEmail: 'a.demaison@geo2france.fr', + published: true, + parentUuid: undefined, + date: new Date('2023-01-01T08:00:00Z'), +}) diff --git a/libs/feature/record/src/lib/feature-record.module.ts b/libs/feature/record/src/lib/feature-record.module.ts index 372a422605..3c9eee16e4 100644 --- a/libs/feature/record/src/lib/feature-record.module.ts +++ b/libs/feature/record/src/lib/feature-record.module.ts @@ -11,7 +11,10 @@ import { MdViewFacade } from './state' import { MdViewEffects } from './state/mdview.effects' import { MapViewComponent } from './map-view/map-view.component' import { DataViewComponent } from './data-view/data-view.component' -import { MD_VIEW_FEATURE_STATE_KEY, reducer } from './state/mdview.reducer' +import { + METADATA_VIEW_FEATURE_STATE_KEY, + reducer, +} from './state/mdview.reducer' import { MatTabsModule } from '@angular/material/tabs' import { MatIconModule } from '@angular/material/icon' import { UiWidgetsModule } from '@geonetwork-ui/ui/widgets' @@ -35,7 +38,7 @@ import { DataViewShareComponent } from './data-view-share/data-view-share.compon ], imports: [ CommonModule, - StoreModule.forFeature(MD_VIEW_FEATURE_STATE_KEY, reducer), + StoreModule.forFeature(METADATA_VIEW_FEATURE_STATE_KEY, reducer), EffectsModule.forFeature([MdViewEffects]), UiLayoutModule, FeatureMapModule, diff --git a/libs/feature/record/src/lib/state/mdview.actions.ts b/libs/feature/record/src/lib/state/mdview.actions.ts index 9b2172888c..9cc84be989 100644 --- a/libs/feature/record/src/lib/state/mdview.actions.ts +++ b/libs/feature/record/src/lib/state/mdview.actions.ts @@ -1,7 +1,13 @@ import { DatavizConfigurationModel } from '@geonetwork-ui/common/domain/model/dataviz/dataviz-configuration.model' import { createAction, props } from '@ngrx/store' -import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' +import { + CatalogRecord, + UserFeedback, +} from '@geonetwork-ui/common/domain/model/record' +/* + Metadata actions + */ export const loadFullMetadata = createAction( '[Metadata view] Load full metadata', props<{ uuid: string }>() @@ -12,24 +18,63 @@ export const setIncompleteMetadata = createAction( props<{ incomplete: Partial }>() ) -export const loadFullSuccess = createAction( - '[Metadata view] Load full success', +export const loadFullMetadataSuccess = createAction( + '[Metadata view] Load full metadata success', props<{ full: CatalogRecord }>() ) -export const loadFullFailure = createAction( - '[Metadata view] Load full failure', +export const loadFullMetadataFailure = createAction( + '[Metadata view] Load full metadata failure', props<{ otherError?: string; notFound?: boolean }>() ) +export const closeMetadata = createAction('[Metadata view] close') + +/* + Related actions + */ export const setRelated = createAction( '[Metadata view] Set related records', props<{ related: CatalogRecord[] }>() ) +/* + ChartConfig actions + */ export const setChartConfig = createAction( '[Metadata view] Set chart config', props<{ chartConfig: DatavizConfigurationModel }>() ) -export const close = createAction('[Metadata view] close') +/* + User Feedbacks actions + */ +export const addUserFeedback = createAction( + '[Metadata view] Add UserFeedback', + props<{ userFeedback: UserFeedback }>() +) + +export const addUserFeedbackSuccess = createAction( + '[Metadata view] Add UserFeedback Success', + props<{ datasetUuid: string }>() +) + +export const addUserFeedbackFailure = createAction( + '[Metadata view] Add UserFeedback Failure', + props<{ otherError?: string; notFound?: boolean }>() +) + +export const loadUserFeedbacks = createAction( + '[Metadata view] Load UserFeedbacks', + props<{ datasetUuid: string }>() +) + +export const loadUserFeedbacksSuccess = createAction( + '[Metadata view] Load UserFeedbacks Success', + props<{ userFeedbacks: UserFeedback[] }>() +) + +export const loadUserFeedbacksFailure = createAction( + '[Metadata view] Load UserFeedbacks Failure', + props<{ otherError?: string; notFound?: boolean }>() +) 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 23b11a8804..066c5abe60 100644 --- a/libs/feature/record/src/lib/state/mdview.effects.spec.ts +++ b/libs/feature/record/src/lib/state/mdview.effects.spec.ts @@ -1,8 +1,10 @@ import { TestBed } from '@angular/core/testing' import { + A_USER_FEEDBACK, DATASET_RECORDS, SAMPLE_AGGREGATIONS_RESULTS, SAMPLE_SEARCH_RESULTS, + SOME_USER_FEEDBACKS, } from '@geonetwork-ui/common/fixtures' import { provideMockActions } from '@ngrx/effects/testing' @@ -15,6 +17,7 @@ import { MdViewEffects } from './mdview.effects' import { hot } from 'jasmine-marbles' import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface' +import { PlatformServiceInterface } from '@geonetwork-ui/common/domain/platform.service.interface' const full = { uniqueIdentifier: '1231321321', @@ -29,10 +32,16 @@ class RecordsRepositoryMock { getSimilarRecords = jest.fn(() => of(DATASET_RECORDS)) } +class PlatformServiceInterfaceMock { + getUserFeedbacks = jest.fn(() => of(SOME_USER_FEEDBACKS)) + postUserFeedbacks = jest.fn(() => of(undefined)) +} + describe('MdViewEffects', () => { let actions: Observable let effects: MdViewEffects let repository: RecordsRepositoryInterface + let platform: PlatformServiceInterface beforeEach(() => { TestBed.configureTestingModule({ @@ -45,23 +54,30 @@ describe('MdViewEffects', () => { provide: RecordsRepositoryInterface, useClass: RecordsRepositoryMock, }, + { + provide: PlatformServiceInterface, + useClass: PlatformServiceInterfaceMock, + }, ], }) repository = TestBed.inject(RecordsRepositoryInterface) effects = TestBed.inject(MdViewEffects) + platform = TestBed.inject(PlatformServiceInterface) }) - describe('loadFullRecord$', () => { + describe('loadFullMetadata$', () => { describe('when api success and at least one record found', () => { it('dispatch loadFullSuccess', () => { actions = hot('-a-|', { a: MdViewActions.loadFullMetadata({ uuid: full.uniqueIdentifier }), }) const expected = hot('-a-|', { - a: MdViewActions.loadFullSuccess({ full: DATASET_RECORDS[0] }), + a: MdViewActions.loadFullMetadataSuccess({ + full: DATASET_RECORDS[0], + }), }) - expect(effects.loadFull$).toBeObservable(expected) + expect(effects.loadFullMetadata$).toBeObservable(expected) }) }) describe('when api success and at no record found', () => { @@ -73,9 +89,9 @@ describe('MdViewEffects', () => { a: MdViewActions.loadFullMetadata({ uuid: full.uniqueIdentifier }), }) const expected = hot('-a-|', { - a: MdViewActions.loadFullFailure({ notFound: true }), + a: MdViewActions.loadFullMetadataFailure({ notFound: true }), }) - expect(effects.loadFull$).toBeObservable(expected) + expect(effects.loadFullMetadata$).toBeObservable(expected) }) }) @@ -90,9 +106,9 @@ describe('MdViewEffects', () => { a: MdViewActions.loadFullMetadata({ uuid: full.uniqueIdentifier }), }) const expected = hot('-(a|)', { - a: MdViewActions.loadFullFailure({ otherError: 'api' }), + a: MdViewActions.loadFullMetadataFailure({ otherError: 'api' }), }) - expect(effects.loadFull$).toBeObservable(expected) + expect(effects.loadFullMetadata$).toBeObservable(expected) }) }) }) @@ -101,7 +117,7 @@ describe('MdViewEffects', () => { describe('when load full success', () => { it('dispatch setRelated', () => { actions = hot('-a-|', { - a: MdViewActions.loadFullSuccess({ full }), + a: MdViewActions.loadFullMetadataSuccess({ full }), }) const expected = hot('-a-|', { a: MdViewActions.setRelated({ related: DATASET_RECORDS }), @@ -115,7 +131,7 @@ describe('MdViewEffects', () => { }) it('dispatch loadFullFailure', () => { actions = hot('-a-|', { - a: MdViewActions.loadFullSuccess({ full }), + a: MdViewActions.loadFullMetadataSuccess({ full }), }) const expected = hot('-(a|)', { a: MdViewActions.setRelated({ related: null }), @@ -124,4 +140,122 @@ describe('MdViewEffects', () => { }) }) }) + + describe('loadUserFeedbacks$', () => { + describe('when loadUserFeedbacks success', () => { + it('should dispatch loadUserFeedbacksSuccess when API call is successful', () => { + actions = hot('-a-', { + a: MdViewActions.loadUserFeedbacks({ datasetUuid: '12345' }), + }) + const expected = hot('-a-', { + a: MdViewActions.loadUserFeedbacksSuccess({ + userFeedbacks: SOME_USER_FEEDBACKS, + }), + }) + + expect(effects.loadUserFeedbacks$).toBeObservable(expected) + }) + }) + + describe('when api fails', () => { + const error = 'API error' + + beforeEach(() => { + platform.getUserFeedbacks = jest.fn(() => + throwError(() => new Error(error)) + ) + }) + + it('should dispatch loadUserFeedbacksFailure when API call fails', () => { + actions = hot('-a|', { + a: MdViewActions.loadUserFeedbacks({ datasetUuid: '12345' }), + }) + const expected = hot('-a|', { + a: MdViewActions.loadUserFeedbacksFailure({ otherError: error }), + }) + + expect(effects.loadUserFeedbacks$).toBeObservable(expected) + }) + }) + }) + + describe('reloadUserFeedbacks$', () => { + describe('when addUserFeedbackSuccess', () => { + it('should dispatch loadUserFeedbacksSuccess when API call is successful', () => { + actions = hot('-a-', { + a: MdViewActions.addUserFeedbackSuccess({ datasetUuid: '12345' }), + }) + const expected = hot('-a', { + a: MdViewActions.loadUserFeedbacksSuccess({ + userFeedbacks: SOME_USER_FEEDBACKS, + }), + }) + + expect(effects.reloadUserFeedbacks$).toBeObservable(expected) + }) + }) + + describe('when api fails', () => { + const error = 'API error' + + beforeEach(() => { + platform.getUserFeedbacks = jest.fn(() => + throwError(() => new Error(error)) + ) + }) + + it('should dispatch loadUserFeedbacksFailure when API call fails', () => { + const error = 'API error' + + actions = hot('-a-', { + a: MdViewActions.addUserFeedbackSuccess({ datasetUuid: '12345' }), + }) + const expected = hot('-a', { + a: MdViewActions.loadUserFeedbacksFailure({ otherError: error }), + }) + + expect(effects.reloadUserFeedbacks$).toBeObservable(expected) + }) + }) + }) + + describe('addUserFeedback$', () => { + describe('when addUserFeedback success', () => { + it('should dispatch addUserFeedbackSuccess when API call is successful', () => { + actions = hot('-a-', { + a: MdViewActions.addUserFeedback({ userFeedback: A_USER_FEEDBACK }), + }) + const expected = hot('-a-', { + a: MdViewActions.addUserFeedbackSuccess({ + datasetUuid: A_USER_FEEDBACK.metadataUUID, + }), + }) + + expect(effects.addUserFeedback$).toBeObservable(expected) + }) + }) + + describe('when api fails', () => { + const error = 'API error' + + beforeEach(() => { + platform.postUserFeedbacks = jest.fn(() => + throwError(() => new Error(error)) + ) + }) + + it('should dispatch addUserFeedbackFailure when API call fails', () => { + const error = 'API error' + + actions = hot('-a-', { + a: MdViewActions.addUserFeedback({ userFeedback: A_USER_FEEDBACK }), + }) + const expected = hot('-a', { + a: MdViewActions.addUserFeedbackFailure({ otherError: error }), + }) + + expect(effects.addUserFeedback$).toBeObservable(expected) + }) + }) + }) }) diff --git a/libs/feature/record/src/lib/state/mdview.effects.ts b/libs/feature/record/src/lib/state/mdview.effects.ts index 66893f90f4..ac98891858 100644 --- a/libs/feature/record/src/lib/state/mdview.effects.ts +++ b/libs/feature/record/src/lib/state/mdview.effects.ts @@ -1,18 +1,23 @@ import { Injectable } from '@angular/core' import { Actions, createEffect, ofType } from '@ngrx/effects' -import { of } from 'rxjs' +import { exhaustMap, mergeMap, of } from 'rxjs' import { catchError, map, switchMap } from 'rxjs/operators' import * as MdViewActions from './mdview.actions' import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface' +import { PlatformServiceInterface } from '@geonetwork-ui/common/domain/platform.service.interface' @Injectable() export class MdViewEffects { constructor( private actions$: Actions, - private recordsRepository: RecordsRepositoryInterface + private recordsRepository: RecordsRepositoryInterface, + private platformServiceInterface: PlatformServiceInterface ) {} - loadFull$ = createEffect(() => + /* + Metadata effects + */ + loadFullMetadata$ = createEffect(() => this.actions$.pipe( ofType(MdViewActions.loadFullMetadata), switchMap(({ uuid }) => @@ -20,19 +25,22 @@ export class MdViewEffects { ), map((record) => { if (record === null) { - return MdViewActions.loadFullFailure({ notFound: true }) + return MdViewActions.loadFullMetadataFailure({ notFound: true }) } - return MdViewActions.loadFullSuccess({ full: record }) + return MdViewActions.loadFullMetadataSuccess({ full: record }) }), catchError((error) => - of(MdViewActions.loadFullFailure({ otherError: error.message })) + of(MdViewActions.loadFullMetadataFailure({ otherError: error.message })) ) ) ) + /* + Related effects + */ loadRelatedRecords$ = createEffect(() => this.actions$.pipe( - ofType(MdViewActions.loadFullSuccess), + ofType(MdViewActions.loadFullMetadataSuccess), switchMap(({ full }) => this.recordsRepository.getSimilarRecords(full)), map((related) => { return MdViewActions.setRelated({ related }) @@ -40,4 +48,71 @@ export class MdViewEffects { catchError((error) => of(MdViewActions.setRelated({ related: null }))) ) ) + + /* + UserFeedback effects + */ + loadUserFeedbacks$ = createEffect(() => + this.actions$.pipe( + ofType(MdViewActions.loadUserFeedbacks), + exhaustMap(({ datasetUuid }) => + this.platformServiceInterface.getUserFeedbacks(datasetUuid).pipe( + map((userFeedbacks) => + MdViewActions.loadUserFeedbacksSuccess({ userFeedbacks }) + ), + catchError((error) => + of( + MdViewActions.loadUserFeedbacksFailure({ + otherError: error.message, + }) + ) + ) + ) + ) + ) + ) + + reloadUserFeedbacks$ = createEffect(() => + this.actions$.pipe( + ofType(MdViewActions.addUserFeedbackSuccess), + exhaustMap(({ datasetUuid }) => + this.platformServiceInterface.getUserFeedbacks(datasetUuid).pipe( + map((userFeedbacks) => + MdViewActions.loadUserFeedbacksSuccess({ userFeedbacks }) + ), + catchError((error) => + of( + MdViewActions.loadUserFeedbacksFailure({ + otherError: error.message, + }) + ) + ) + ) + ) + ) + ) + + addUserFeedback$ = createEffect(() => + this.actions$.pipe( + ofType(MdViewActions.addUserFeedback), + mergeMap((action) => + this.platformServiceInterface + .postUserFeedbacks(action.userFeedback) + .pipe( + map(() => + MdViewActions.addUserFeedbackSuccess({ + datasetUuid: action.userFeedback.metadataUUID, + }) + ), + catchError((error) => { + return of( + MdViewActions.addUserFeedbackFailure({ + otherError: error.message, + }) + ) + }) + ) + ) + ) + ) } diff --git a/libs/feature/record/src/lib/state/mdview.facade.spec.ts b/libs/feature/record/src/lib/state/mdview.facade.spec.ts index f915487c2f..e83b9dbdd4 100644 --- a/libs/feature/record/src/lib/state/mdview.facade.spec.ts +++ b/libs/feature/record/src/lib/state/mdview.facade.spec.ts @@ -1,23 +1,39 @@ import { TestBed } from '@angular/core/testing' import { MockStore, provideMockStore } from '@ngrx/store/testing' -import { initialMdviewState, MD_VIEW_FEATURE_STATE_KEY } from './mdview.reducer' +import { + initialMetadataViewState, + METADATA_VIEW_FEATURE_STATE_KEY, +} from './mdview.reducer' import { MdViewFacade } from './mdview.facade' import * as MdViewActions from './mdview.actions' import { hot } from 'jasmine-marbles' -import { DATASET_RECORDS } from '@geonetwork-ui/common/fixtures' +import { + A_USER_FEEDBACK, + DATASET_RECORDS, +} from '@geonetwork-ui/common/fixtures' +import { DatavizConfigurationModel } from '@geonetwork-ui/common/domain/model/dataviz/dataviz-configuration.model' +import { AvatarServiceInterface } from '@geonetwork-ui/api/repository' describe('MdViewFacade', () => { let store: MockStore let facade: MdViewFacade + const chartConfigMock: DatavizConfigurationModel = { + aggregation: 'sum', + xProperty: 'anneeappro', + yProperty: 'nbre_com', + chartType: 'bar', + } + beforeEach(() => { TestBed.configureTestingModule({ imports: [], providers: [ MdViewFacade, + AvatarServiceInterface, provideMockStore({ initialState: { - [MD_VIEW_FEATURE_STATE_KEY]: initialMdviewState, + [METADATA_VIEW_FEATURE_STATE_KEY]: initialMetadataViewState, }, }), ], @@ -25,15 +41,17 @@ describe('MdViewFacade', () => { store = TestBed.inject(MockStore) facade = TestBed.inject(MdViewFacade) }) + describe('isPresent$', () => { it('emits false if no metadata', () => { const expected = hot('a', { a: false }) expect(facade.isPresent$).toBeObservable(expected) }) + it('emits true if metadata', () => { store.setState({ - [MD_VIEW_FEATURE_STATE_KEY]: { - ...initialMdviewState, + [METADATA_VIEW_FEATURE_STATE_KEY]: { + ...initialMetadataViewState, metadata: DATASET_RECORDS[0], }, }) @@ -41,15 +59,17 @@ describe('MdViewFacade', () => { expect(facade.isPresent$).toBeObservable(expected) }) }) + describe('metadata$', () => { it('does not emit if no metadata', () => { const expected = hot('-') expect(facade.metadata$).toBeObservable(expected) }) + it('emits metadata if present', () => { store.setState({ - [MD_VIEW_FEATURE_STATE_KEY]: { - ...initialMdviewState, + [METADATA_VIEW_FEATURE_STATE_KEY]: { + ...initialMetadataViewState, metadata: DATASET_RECORDS[0], }, }) @@ -57,15 +77,17 @@ describe('MdViewFacade', () => { expect(facade.metadata$).toBeObservable(expected) }) }) + describe('allLinks$', () => { it('does not emit if no links', () => { const expected = hot('-') expect(facade.allLinks$).toBeObservable(expected) }) + it('emits allLinks if present', () => { store.setState({ - [MD_VIEW_FEATURE_STATE_KEY]: { - ...initialMdviewState, + [METADATA_VIEW_FEATURE_STATE_KEY]: { + ...initialMetadataViewState, metadata: DATASET_RECORDS[0], }, }) @@ -73,11 +95,12 @@ describe('MdViewFacade', () => { expect(facade.allLinks$).toBeObservable(expected) }) }) + describe('isIncomplete$', () => { it('emits true if full record is loading', () => { store.setState({ - [MD_VIEW_FEATURE_STATE_KEY]: { - ...initialMdviewState, + [METADATA_VIEW_FEATURE_STATE_KEY]: { + ...initialMetadataViewState, metadata: DATASET_RECORDS[0], loadingFull: true, }, @@ -85,10 +108,11 @@ describe('MdViewFacade', () => { const expected = hot('a', { a: true }) expect(facade.isIncomplete$).toBeObservable(expected) }) + it('emits false if full metadata loaded', () => { store.setState({ - [MD_VIEW_FEATURE_STATE_KEY]: { - ...initialMdviewState, + [METADATA_VIEW_FEATURE_STATE_KEY]: { + ...initialMetadataViewState, metadata: DATASET_RECORDS[0], loadingFull: false, }, @@ -96,45 +120,52 @@ describe('MdViewFacade', () => { const expected = hot('a', { a: false }) expect(facade.isIncomplete$).toBeObservable(expected) }) + it('does not emit if no metadata', () => { const expected = hot('-') expect(facade.isIncomplete$).toBeObservable(expected) }) }) + describe('error$', () => { let values + beforeEach(() => { values = [] facade.error$.subscribe((v) => values.push(v)) }) + it('emits the error if any', () => { store.setState({ - [MD_VIEW_FEATURE_STATE_KEY]: { - ...initialMdviewState, + [METADATA_VIEW_FEATURE_STATE_KEY]: { + ...initialMetadataViewState, error: 'something went wrong', }, }) expect(values).toEqual([null, 'something went wrong']) }) + it('emits null if no error', () => { expect(values).toEqual([null]) }) + it('emits the error and null', () => { store.setState({ - [MD_VIEW_FEATURE_STATE_KEY]: { - ...initialMdviewState, + [METADATA_VIEW_FEATURE_STATE_KEY]: { + ...initialMetadataViewState, error: 'something went wrong', }, }) store.setState({ - [MD_VIEW_FEATURE_STATE_KEY]: { - ...initialMdviewState, + [METADATA_VIEW_FEATURE_STATE_KEY]: { + ...initialMetadataViewState, error: null, }, }) expect(values).toEqual([null, 'something went wrong', null]) }) }) + describe('setIncompleteMetadata', () => { it('dispatches a setIncompleteMetadata action', () => { facade.setIncompleteMetadata(DATASET_RECORDS[0]) @@ -146,20 +177,42 @@ describe('MdViewFacade', () => { expect(store.scannedActions$).toBeObservable(expected) }) }) - describe('close', () => { + + describe('closeMetadata', () => { it('dispatches a close action', () => { - facade.close() + facade.closeMetadata() const expected = hot('a', { - a: MdViewActions.close(), + a: MdViewActions.closeMetadata(), }) expect(store.scannedActions$).toBeObservable(expected) }) }) + describe('setChartConfig', () => { it('dispatches a setChartConfig action', () => { - facade.setChartConfig() + facade.setChartConfig(chartConfigMock) + const expected = hot('a', { + a: MdViewActions.setChartConfig({ chartConfig: chartConfigMock }), + }) + expect(store.scannedActions$).toBeObservable(expected) + }) + }) + + describe('addUserFeedback', () => { + it('dispatches a addUserFeedback action', () => { + facade.addUserFeedback(A_USER_FEEDBACK) + const expected = hot('a', { + a: MdViewActions.addUserFeedback({ userFeedback: A_USER_FEEDBACK }), + }) + expect(store.scannedActions$).toBeObservable(expected) + }) + }) + + describe('loadUserFeedbacks', () => { + it('dispatches a loadUserFeedbacks action', () => { + facade.loadUserFeedbacks(expect.any(Number)) const expected = hot('a', { - a: MdViewActions.setChartConfig(), + a: MdViewActions.loadUserFeedbacks({ datasetUuid: expect.any(Number) }), }) expect(store.scannedActions$).toBeObservable(expected) }) diff --git a/libs/feature/record/src/lib/state/mdview.facade.ts b/libs/feature/record/src/lib/state/mdview.facade.ts index a2e4a0064f..c8aa010cbb 100644 --- a/libs/feature/record/src/lib/state/mdview.facade.ts +++ b/libs/feature/record/src/lib/state/mdview.facade.ts @@ -5,7 +5,12 @@ import * as MdViewActions from './mdview.actions' import * as MdViewSelectors from './mdview.selectors' import { LinkClassifierService, LinkUsage } from '@geonetwork-ui/util/shared' import { DatavizConfigurationModel } from '@geonetwork-ui/common/domain/model/dataviz/dataviz-configuration.model' -import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' +import { + CatalogRecord, + UserFeedback, + UserFeedbackViewModel, +} from '@geonetwork-ui/common/domain/model/record' +import { AvatarServiceInterface } from '@geonetwork-ui/api/repository' @Injectable() /** @@ -15,19 +20,31 @@ import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' * To clear the current record use the `close()` method. */ export class MdViewFacade { + constructor( + private store: Store, + private linkClassifier: LinkClassifierService, + private avatarService: AvatarServiceInterface + ) {} + isPresent$ = this.store.pipe( select(MdViewSelectors.getMetadataUuid), map((uuid) => !!uuid) ) - isLoading$ = this.store.pipe(select(MdViewSelectors.getMetadataIsLoading)) + + isMetadataLoading$ = this.store.pipe( + select(MdViewSelectors.getMetadataIsLoading) + ) + metadata$ = this.store.pipe( select(MdViewSelectors.getMetadata), filter((md) => !!md) ) + isIncomplete$ = this.store.pipe( select(MdViewSelectors.getMetadataIsIncomplete), filter((incomplete) => incomplete !== null) ) + error$ = this.store.pipe(select(MdViewSelectors.getMetadataError)) related$ = this.store.pipe(select(MdViewSelectors.getRelated)) @@ -37,11 +54,13 @@ export class MdViewFacade { allLinks$ = this.metadata$.pipe( map((record) => ('distributions' in record ? record.distributions : [])) ) + apiLinks$ = this.allLinks$.pipe( map((links) => links.filter((link) => this.linkClassifier.hasUsage(link, LinkUsage.API)) ) ) + mapApiLinks$ = this.allLinks$.pipe( map((links) => links.filter((link) => @@ -49,6 +68,7 @@ export class MdViewFacade { ) ) ) + downloadLinks$ = this.allLinks$.pipe( map((links) => links.filter((link) => @@ -56,11 +76,13 @@ export class MdViewFacade { ) ) ) + dataLinks$ = this.allLinks$.pipe( map((links) => links.filter((link) => this.linkClassifier.hasUsage(link, LinkUsage.DATA)) ) ) + geoDataLinks$ = this.allLinks$.pipe( map((links) => links.filter((link) => @@ -68,9 +90,11 @@ export class MdViewFacade { ) ) ) + landingPageLinks$ = this.metadata$.pipe( map((record) => ('landingPage' in record ? [record.landingPage] : [])) ) + otherLinks$ = this.allLinks$.pipe( map((links) => links.filter((link) => @@ -79,10 +103,13 @@ export class MdViewFacade { ) ) - constructor( - private store: Store, - private linkClassifier: LinkClassifierService - ) {} + userFeedbacks$ = this.store.pipe(select(MdViewSelectors.getUserFeedbacks)) + isAllUserFeedbackLoading$ = this.store.pipe( + select(MdViewSelectors.getAllUserFeedbacksLoading) + ) + isAddUserFeedbackLoading$ = this.store.pipe( + select(MdViewSelectors.getAddUserFeedbacksLoading) + ) /** * This will show an incomplete record (e.g. from a search result) as a preview @@ -91,16 +118,30 @@ export class MdViewFacade { setIncompleteMetadata(incomplete: CatalogRecord) { this.store.dispatch(MdViewActions.setIncompleteMetadata({ incomplete })) } + /** * This will trigger the load of a full metadata record */ loadFull(uuid: string) { this.store.dispatch(MdViewActions.loadFullMetadata({ uuid })) } - close() { - this.store.dispatch(MdViewActions.close()) + + closeMetadata() { + this.store.dispatch(MdViewActions.closeMetadata()) } + setChartConfig(chartConfig: DatavizConfigurationModel) { this.store.dispatch(MdViewActions.setChartConfig({ chartConfig })) } + + /** + * UserFeedbacks + */ + addUserFeedback(userFeedback: UserFeedback) { + this.store.dispatch(MdViewActions.addUserFeedback({ userFeedback })) + } + + loadUserFeedbacks(datasetUuid: string) { + this.store.dispatch(MdViewActions.loadUserFeedbacks({ datasetUuid })) + } } diff --git a/libs/feature/record/src/lib/state/mdview.reducer.spec.ts b/libs/feature/record/src/lib/state/mdview.reducer.spec.ts index 148f42bc96..3bc1acfca9 100644 --- a/libs/feature/record/src/lib/state/mdview.reducer.spec.ts +++ b/libs/feature/record/src/lib/state/mdview.reducer.spec.ts @@ -1,47 +1,51 @@ import * as MdViewActions from './mdview.actions' -import { initialMdviewState, reducer } from './mdview.reducer' -import { DATASET_RECORDS } from '@geonetwork-ui/common/fixtures' +import { initialMetadataViewState, reducer } from './mdview.reducer' +import { + A_USER_FEEDBACK, + DATASET_RECORDS, + SOME_USER_FEEDBACKS, +} from '@geonetwork-ui/common/fixtures' +import { DatavizConfigurationModel } from '@geonetwork-ui/common/domain/model/dataviz/dataviz-configuration.model' -const chartConfigMock = { +const chartConfigMock: DatavizConfigurationModel = { aggregation: 'sum', xProperty: 'anneeappro', yProperty: 'nbre_com', chartType: 'bar', } -const withErrorMdViewState = { - ...initialMdviewState, - error: { otherError: 'Some error' }, -} - -describe('MdView Reducer', () => { +describe('metadataViewReducer', () => { describe('undefined action', () => { it('should return the default state', () => { const action = {} as any const state = reducer(undefined, action) - expect(state).toBe(initialMdviewState) + expect(state).toBe(initialMetadataViewState) }) }) describe('loadFullMetadata', () => { let action + beforeEach(() => { action = MdViewActions.loadFullMetadata({ - uniqueIdentifier: '123132132132132132', + uuid: '123132132132132132', }) }) + it('store the loading state', () => { - const state = reducer(withErrorMdViewState, action) + const state = reducer(initialMetadataViewState, action) expect(state).toEqual({ - ...withErrorMdViewState, + ...initialMetadataViewState, error: null, loadingFull: true, }) }) }) + describe('setIncompleteMetadata', () => { let action + beforeEach(() => { const { uniqueIdentifier, title, ...rest } = DATASET_RECORDS[0] action = MdViewActions.setIncompleteMetadata({ @@ -51,10 +55,11 @@ describe('MdView Reducer', () => { }, }) }) + it('saves incomplete metadata', () => { - const state = reducer(withErrorMdViewState, action) + const state = reducer(initialMetadataViewState, action) expect(state).toEqual({ - ...withErrorMdViewState, + ...initialMetadataViewState, error: null, metadata: { title: @@ -64,92 +69,190 @@ describe('MdView Reducer', () => { }) }) }) - describe('loadFullRecordSuccess', () => { + + describe('loadFullMetadataSuccess', () => { let action + beforeEach(() => { - action = MdViewActions.loadFullSuccess({ + action = MdViewActions.loadFullMetadataSuccess({ full: DATASET_RECORDS[0], }) }) + it('saves full metadata ', () => { const state = reducer( - { ...withErrorMdViewState, loadingFull: true }, + { ...initialMetadataViewState, loadingFull: true }, action ) expect(state).toEqual({ - ...withErrorMdViewState, + ...initialMetadataViewState, error: null, loadingFull: false, metadata: DATASET_RECORDS[0], }) }) }) + describe('loadFullRecordFailure', () => { let action + beforeEach(() => { - action = MdViewActions.loadFullFailure({ - otherError: 'error', + action = MdViewActions.loadFullMetadataFailure({ + otherError: 'Some error', notFound: true, }) }) + it('set error', () => { const state = reducer( - { ...initialMdviewState, loadingFull: true }, + { ...initialMetadataViewState, loadingFull: true }, action ) expect(state).toEqual({ - ...initialMdviewState, + ...initialMetadataViewState, loadingFull: false, - error: { otherError: 'error', notFound: true }, + error: { otherError: 'Some error', notFound: true }, }) }) }) + describe('setRelated', () => { let action + beforeEach(() => { action = MdViewActions.setRelated({ related: [DATASET_RECORDS[1]], }) }) + it('set related records', () => { - const state = reducer({ ...initialMdviewState }, action) + const state = reducer({ ...initialMetadataViewState }, action) expect(state).toEqual({ - ...initialMdviewState, + ...initialMetadataViewState, related: [DATASET_RECORDS[1]], }) }) }) + describe('setChartConfig', () => { let action + beforeEach(() => { action = MdViewActions.setChartConfig({ - chartConfig: [chartConfigMock], + chartConfig: chartConfigMock, }) }) + it('set chart config', () => { - const state = reducer({ ...initialMdviewState }, action) + const state = reducer({ ...initialMetadataViewState }, action) expect(state).toEqual({ - ...initialMdviewState, - chartConfig: [chartConfigMock], + ...initialMetadataViewState, + chartConfig: chartConfigMock, }) }) }) - describe('close', () => { + + describe('closeMetadata', () => { let action + beforeEach(() => { - action = MdViewActions.close() + action = MdViewActions.closeMetadata() }) + it('set error', () => { const state = reducer( { - ...initialMdviewState, + ...initialMetadataViewState, related: [DATASET_RECORDS[1]], loadingFull: false, metadata: DATASET_RECORDS[0], }, action ) - expect(state).toEqual(initialMdviewState) + expect(state).toEqual(initialMetadataViewState) + }) + }) + + describe('loadUserFeedbacks', () => { + let action + + beforeEach(() => { + action = MdViewActions.loadUserFeedbacks({ + datasetUuid: expect.any(Number), + }) + }) + + it('return states without error and with allUserFeedbacksLoading true', () => { + const state = reducer(initialMetadataViewState, action) + expect(state).toEqual({ + ...initialMetadataViewState, + error: null, + loadingFull: false, + allUserFeedbacksLoading: true, + addUserFeedbackLoading: false, + }) + }) + }) + + describe('addUserFeedback', () => { + let action + + beforeEach(() => { + action = MdViewActions.addUserFeedback({ + userFeedback: A_USER_FEEDBACK, + }) + }) + + it('return states without error and with addUserFeedbackLoading true', () => { + const state = reducer({ ...initialMetadataViewState }, action) + expect(state).toEqual({ + ...initialMetadataViewState, + addUserFeedbackLoading: true, + }) + }) + }) + + describe('loadUserFeedbacksSuccess', () => { + let action + + beforeEach(() => { + action = MdViewActions.loadUserFeedbacksSuccess({ + userFeedbacks: SOME_USER_FEEDBACKS, + }) + }) + + it('return states without error and with userfeedbacks', () => { + const state = reducer( + { ...initialMetadataViewState, allUserFeedbacksLoading: true }, + action + ) + expect(state).toEqual({ + ...initialMetadataViewState, + error: null, + addUserFeedbackLoading: false, + allUserFeedbacksLoading: false, + loadingFull: false, + userFeedbacks: SOME_USER_FEEDBACKS, + }) + }) + }) + + describe('loadUserFeedbacksFailure', () => { + let action + + beforeEach(() => { + action = MdViewActions.loadUserFeedbacksFailure({ + otherError: 'Some error', + notFound: true, + }) + }) + + it('set error', () => { + const state = reducer({ ...initialMetadataViewState }, action) + expect(state).toEqual({ + ...initialMetadataViewState, + error: { otherError: 'Some error', notFound: true }, + }) }) }) }) diff --git a/libs/feature/record/src/lib/state/mdview.reducer.ts b/libs/feature/record/src/lib/state/mdview.reducer.ts index 81b6ba7e08..7e9be2eccd 100644 --- a/libs/feature/record/src/lib/state/mdview.reducer.ts +++ b/libs/feature/record/src/lib/state/mdview.reducer.ts @@ -1,61 +1,118 @@ import { Action, createReducer, on } from '@ngrx/store' -import * as MdViewActions from './mdview.actions' +import * as MetadataViewActions from './mdview.actions' import { DatavizConfigurationModel } from '@geonetwork-ui/common/domain/model/dataviz/dataviz-configuration.model' -import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' +import { + CatalogRecord, + UserFeedback, +} from '@geonetwork-ui/common/domain/model/record' -export const MD_VIEW_FEATURE_STATE_KEY = 'mdView' +export const METADATA_VIEW_FEATURE_STATE_KEY = 'metadataView' -export interface MdViewState { +export interface MetadataViewState { loadingFull: boolean error: { notFound?: boolean; otherError?: string } | null metadata?: Partial related?: CatalogRecord[] + userFeedbacks?: UserFeedback[] + allUserFeedbacksLoading: boolean + addUserFeedbackLoading: boolean chartConfig?: DatavizConfigurationModel } -export const initialMdviewState: MdViewState = { +export const initialMetadataViewState: MetadataViewState = { error: null, loadingFull: false, + allUserFeedbacksLoading: false, + addUserFeedbackLoading: false, } -const mdViewReducer = createReducer( - initialMdviewState, - on(MdViewActions.loadFullMetadata, (state) => ({ +const metadataViewReducer = createReducer( + initialMetadataViewState, + + /* + Metadata reducers + */ + on(MetadataViewActions.loadFullMetadata, (state) => ({ ...state, error: null, loadingFull: true, })), - on(MdViewActions.setIncompleteMetadata, (state, { incomplete }) => ({ + on(MetadataViewActions.setIncompleteMetadata, (state, { incomplete }) => ({ ...state, error: null, metadata: incomplete, })), - on(MdViewActions.loadFullSuccess, (state, { full }) => ({ + on(MetadataViewActions.loadFullMetadataSuccess, (state, { full }) => ({ ...state, error: null, metadata: full, loadingFull: false, })), - on(MdViewActions.loadFullFailure, (state, { otherError, notFound }) => ({ - ...state, - error: { otherError, notFound }, - loadingFull: false, - })), - on(MdViewActions.setRelated, (state, { related }) => ({ + on( + MetadataViewActions.loadFullMetadataFailure, + (state, { otherError, notFound }) => ({ + ...state, + error: { otherError, notFound }, + loadingFull: false, + }) + ), + on(MetadataViewActions.closeMetadata, (state) => { + const { metadata, related, userFeedbacks, ...stateWithoutMetadata } = state + return stateWithoutMetadata + }), + + /* + Related reducers + */ + on(MetadataViewActions.setRelated, (state, { related }) => ({ ...state, related, })), - on(MdViewActions.setChartConfig, (state, { chartConfig }) => ({ + + /* + ChartConfig reducers + */ + on(MetadataViewActions.setChartConfig, (state, { chartConfig }) => ({ ...state, chartConfig, })), - on(MdViewActions.close, (state) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { metadata, related, ...stateWithoutMd } = state - return stateWithoutMd - }) + + /* + UserFeedbacks reducers + */ + on(MetadataViewActions.loadUserFeedbacks, (state) => ({ + ...state, + error: null, + allUserFeedbacksLoading: true, + })), + on(MetadataViewActions.addUserFeedback, (state) => ({ + ...state, + addUserFeedbackLoading: true, + })), + on( + MetadataViewActions.loadUserFeedbacksSuccess, + (state, { userFeedbacks }) => ({ + ...state, + error: null, + userFeedbacks: userFeedbacks, + addUserFeedbackLoading: false, + allUserFeedbacksLoading: false, + }) + ), + on( + MetadataViewActions.loadUserFeedbacksFailure, + (state, { otherError, notFound }) => ({ + ...state, + error: { otherError, notFound }, + addUserFeedbackLoading: false, + allUserFeedbacksLoading: false, + }) + ) ) -export function reducer(state: MdViewState | undefined, action: Action) { - return mdViewReducer(state, action) +export function reducer( + metadataViewState: MetadataViewState | undefined, + action: Action +) { + return metadataViewReducer(metadataViewState, action) } diff --git a/libs/feature/record/src/lib/state/mdview.selectors.spec.ts b/libs/feature/record/src/lib/state/mdview.selectors.spec.ts index 54b9ef8f59..23524200d6 100644 --- a/libs/feature/record/src/lib/state/mdview.selectors.spec.ts +++ b/libs/feature/record/src/lib/state/mdview.selectors.spec.ts @@ -34,20 +34,25 @@ describe('MdView Selectors', () => { const results = MdViewSelectors.getMetadataUuid.projector(state) expect(results).toBe('321321321321') }) + it('returns null if no metadata in the state', () => { const results = MdViewSelectors.getMetadataUuid.projector({ loadingFull: false, error: null, + allUserFeedbacksLoading: false, + addUserFeedbackLoading: false, }) expect(results).toBe(null) }) }) + describe('getMetadata', () => { it('returns the metadata in the state', () => { const results = MdViewSelectors.getMetadata.projector(state) expect(results).toBe(state.metadata) }) }) + describe('getMetadataIsIncomplete', () => { it('returns true when incomplete', () => { const results = MdViewSelectors.getMetadataIsIncomplete.projector({ @@ -56,23 +61,29 @@ describe('MdView Selectors', () => { }) expect(results).toBe(true) }) + it('returns false when complete', () => { const results = MdViewSelectors.getMetadataIsIncomplete.projector(state) expect(results).toBe(false) }) + it('returns null if no metadata', () => { const results = MdViewSelectors.getMetadataIsIncomplete.projector({ loadingFull: false, error: null, + allUserFeedbacksLoading: false, + addUserFeedbackLoading: false, }) expect(results).toBe(null) }) }) + describe('getMetadataIsLoading', () => { it('returns false if not loading', () => { const results = MdViewSelectors.getMetadataIsLoading.projector(state) expect(results).toBe(false) }) + it('returns true if loading', () => { const results = MdViewSelectors.getMetadataIsLoading.projector({ ...state, @@ -81,6 +92,7 @@ describe('MdView Selectors', () => { expect(results).toBe(true) }) }) + describe('getMetadataError', () => { it('returns error if present', () => { const results = MdViewSelectors.getMetadataError.projector({ @@ -89,14 +101,18 @@ describe('MdView Selectors', () => { }) expect(results).toBe('ouch') }) + it('returns null if no error', () => { const results = MdViewSelectors.getMetadataError.projector({ loadingFull: false, error: null, + allUserFeedbacksLoading: false, + addUserFeedbackLoading: false, }) expect(results).toBe(null) }) }) + describe('getRelated', () => { it('returns related records', () => { const results = MdViewSelectors.getRelated.projector({ @@ -106,6 +122,7 @@ describe('MdView Selectors', () => { expect(results).toEqual([relatedRecord]) }) }) + describe('getChartConfig', () => { it('returns chart config', () => { const results = MdViewSelectors.getChartConfig.projector({ diff --git a/libs/feature/record/src/lib/state/mdview.selectors.ts b/libs/feature/record/src/lib/state/mdview.selectors.ts index ee79e613be..bb035e12a7 100644 --- a/libs/feature/record/src/lib/state/mdview.selectors.ts +++ b/libs/feature/record/src/lib/state/mdview.selectors.ts @@ -1,36 +1,66 @@ import { createFeatureSelector, createSelector } from '@ngrx/store' -import { MD_VIEW_FEATURE_STATE_KEY, MdViewState } from './mdview.reducer' +import { + METADATA_VIEW_FEATURE_STATE_KEY, + MetadataViewState, +} from './mdview.reducer' -export const getMdViewState = createFeatureSelector( - MD_VIEW_FEATURE_STATE_KEY +export const getMdViewState = createFeatureSelector( + METADATA_VIEW_FEATURE_STATE_KEY ) +/* + Metadata selectors +*/ export const getMetadataUuid = createSelector( getMdViewState, - (state: MdViewState) => + (state: MetadataViewState) => state.metadata ? state.metadata.uniqueIdentifier : null ) export const getMetadata = createSelector( getMdViewState, - (state: MdViewState) => state.metadata + (state: MetadataViewState) => state.metadata ) export const getMetadataIsIncomplete = createSelector( getMdViewState, - (state: MdViewState) => (state.metadata ? state.loadingFull : null) + (state: MetadataViewState) => (state.metadata ? state.loadingFull : null) ) export const getMetadataIsLoading = createSelector( getMdViewState, - (state: MdViewState) => state.loadingFull + (state: MetadataViewState) => state.loadingFull ) export const getMetadataError = createSelector( getMdViewState, - (state: MdViewState) => state.error + (state: MetadataViewState) => state.error ) + +/* + Related selectors +*/ export const getRelated = createSelector( getMdViewState, - (state: MdViewState) => state.related + (state: MetadataViewState) => state.related ) + +/* + Metadata selectors +*/ export const getChartConfig = createSelector( getMdViewState, - (state: MdViewState) => state.chartConfig + (state: MetadataViewState) => state.chartConfig +) + +/* + UserFeedback selectors +*/ +export const getUserFeedbacks = createSelector( + getMdViewState, + (state: MetadataViewState) => state.userFeedbacks +) +export const getAllUserFeedbacksLoading = createSelector( + getMdViewState, + (state: MetadataViewState) => state.allUserFeedbacksLoading +) +export const getAddUserFeedbacksLoading = createSelector( + getMdViewState, + (state: MetadataViewState) => state.addUserFeedbackLoading ) diff --git a/libs/feature/router/src/lib/default/state/router.effects.spec.ts b/libs/feature/router/src/lib/default/state/router.effects.spec.ts index b2faf92c08..4936c377e5 100644 --- a/libs/feature/router/src/lib/default/state/router.effects.spec.ts +++ b/libs/feature/router/src/lib/default/state/router.effects.spec.ts @@ -165,7 +165,7 @@ describe('RouterEffects', () => { } as any), }) const expected = hot('-a', { - a: MdViewActions.close(), + a: MdViewActions.closeMetadata(), }) expect(effects.navigateToSearch$).toBeObservable(expected) }) diff --git a/libs/feature/router/src/lib/default/state/router.effects.ts b/libs/feature/router/src/lib/default/state/router.effects.ts index 822b6c9a2b..19a95c8f8c 100644 --- a/libs/feature/router/src/lib/default/state/router.effects.ts +++ b/libs/feature/router/src/lib/default/state/router.effects.ts @@ -15,7 +15,7 @@ import { } from '@geonetwork-ui/common/domain/model/search' import { Actions, createEffect, ofType } from '@ngrx/effects' import { navigation } from '@ngrx/router-store/data-persistence' -import { of, pairwise, startWith, withLatestFrom } from 'rxjs' +import { of, pairwise, startWith } from 'rxjs' import { map, mergeMap, tap } from 'rxjs/operators' import * as RouterActions from './router.actions' import { RouterFacade } from './router.facade' @@ -141,7 +141,7 @@ export class RouterEffects { navigateToSearch$ = createEffect(() => this._actions$.pipe( navigation(this.routerConfig.searchRouteComponent, { - run: () => MdViewActions.close(), + run: () => MdViewActions.closeMetadata(), }) ) ) diff --git a/libs/ui/elements/src/lib/ui-elements.module.ts b/libs/ui/elements/src/lib/ui-elements.module.ts index d2f7f9d2ab..64373c318c 100644 --- a/libs/ui/elements/src/lib/ui-elements.module.ts +++ b/libs/ui/elements/src/lib/ui-elements.module.ts @@ -31,6 +31,8 @@ import { MaxLinesComponent } from './max-lines/max-lines.component' import { RecordApiFormComponent } from './record-api-form/record-api-form.component' import { MarkdownParserComponent } from './markdown-parser/markdown-parser.component' import { ImageOverlayPreviewComponent } from './image-overlay-preview/image-overlay-preview.component' +import { UserFeedbackItemComponent } from './user-feedback-item/user-feedback-item.component' +import { TimeSincePipe } from './user-feedback-item/time-since.pipe' @NgModule({ imports: [ @@ -47,6 +49,7 @@ import { ImageOverlayPreviewComponent } from './image-overlay-preview/image-over NgOptimizedImage, MarkdownParserComponent, ThumbnailComponent, + TimeSincePipe, ], declarations: [ MetadataInfoComponent, @@ -68,6 +71,7 @@ import { ImageOverlayPreviewComponent } from './image-overlay-preview/image-over PaginationButtonsComponent, MaxLinesComponent, RecordApiFormComponent, + UserFeedbackItemComponent, ImageOverlayPreviewComponent, ], exports: [ @@ -91,6 +95,7 @@ import { ImageOverlayPreviewComponent } from './image-overlay-preview/image-over MaxLinesComponent, RecordApiFormComponent, MarkdownParserComponent, + UserFeedbackItemComponent, ImageOverlayPreviewComponent, ], }) diff --git a/libs/ui/elements/src/lib/user-feedback-item/time-since.pipe.ts b/libs/ui/elements/src/lib/user-feedback-item/time-since.pipe.ts new file mode 100644 index 0000000000..ae604a420e --- /dev/null +++ b/libs/ui/elements/src/lib/user-feedback-item/time-since.pipe.ts @@ -0,0 +1,54 @@ +import { Pipe, PipeTransform } from '@angular/core' +import { TranslateService } from '@ngx-translate/core' +import { formatDistance } from 'date-fns' +import { de, enUS, es, fr, it, nl, pt, sk } from 'date-fns/locale' + +@Pipe({ + name: 'timeSince', + standalone: true, +}) +export class TimeSincePipe implements PipeTransform { + constructor(private translate: TranslateService) {} + + transform(value: Date): string { + if (isNaN(value.getTime())) { + throw new Error('Invalid Date') + } + + const maintenant = new Date() + let locale: Locale + + switch (this.translate.currentLang) { + case 'fr': + locale = fr + break + case 'de': + locale = de + break + case 'es': + locale = es + break + case 'it': + locale = it + break + case 'nl': + locale = nl + break + case 'pt': + locale = pt + break + case 'sk': + locale = sk + break + case 'en': + default: + locale = enUS + break + } + + return formatDistance(value, maintenant, { + addSuffix: true, + locale: locale, + }) + } +} diff --git a/libs/ui/elements/src/lib/user-feedback-item/user-feedback-item.component.css b/libs/ui/elements/src/lib/user-feedback-item/user-feedback-item.component.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/ui/elements/src/lib/user-feedback-item/user-feedback-item.component.html b/libs/ui/elements/src/lib/user-feedback-item/user-feedback-item.component.html new file mode 100644 index 0000000000..f91bd17d7e --- /dev/null +++ b/libs/ui/elements/src/lib/user-feedback-item/user-feedback-item.component.html @@ -0,0 +1,75 @@ +
+
+
+ avatar +
+
+ {{ userFeedbackParent.authorName }} + {{ userFeedbackParent.date | timeSince }} +
+
+
+ {{ userFeedbackParent.comment }} +
+
+
+ +
+ +
+
+
+ +
+ + + send + + +
+ +
+
+
+
+
+
+
diff --git a/libs/ui/elements/src/lib/user-feedback-item/user-feedback-item.component.spec.ts b/libs/ui/elements/src/lib/user-feedback-item/user-feedback-item.component.spec.ts new file mode 100644 index 0000000000..bb347a200f --- /dev/null +++ b/libs/ui/elements/src/lib/user-feedback-item/user-feedback-item.component.spec.ts @@ -0,0 +1,63 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { TranslateModule } from '@ngx-translate/core' +import { UserFeedbackItemComponent } from './user-feedback-item.component' +import { ChangeDetectionStrategy } from '@angular/core' +import { SOME_USER_FEEDBACKS } from '@geonetwork-ui/common/fixtures' +import { TimeSincePipe } from './time-since.pipe' + +describe('UserFeedbackItemComponent', () => { + let component: UserFeedbackItemComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [UserFeedbackItemComponent], + imports: [TranslateModule.forRoot(), TimeSincePipe], + }) + .overrideComponent(UserFeedbackItemComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default }, + }) + .compileComponents() + }) + + beforeEach(() => { + fixture = TestBed.createComponent(UserFeedbackItemComponent) + component = fixture.componentInstance + component.userFeedbackParent = { ...SOME_USER_FEEDBACKS[0], avatarUrl: '' } + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) + + describe('publishNewAnswer()', () => { + it('should not emit new answer if new answer is empty', () => { + component.newAnswer = '' + component.newUserFeedbackAnswer.emit = jest.fn() + component.publishNewAnswer() + expect(component.newUserFeedbackAnswer.emit).not.toHaveBeenCalled() + }) + + it('should emit new answer if new answer is not empty', () => { + component.newAnswer = 'This is a new answer' + component.newUserFeedbackAnswer.emit = jest.fn() + component.publishNewAnswer() + expect(component.newUserFeedbackAnswer.emit).toHaveBeenCalled() + }) + }) + + describe('onNewAnswerValueChange()', () => { + it('should set isAnswerEmpty to true if new answer is empty', () => { + component.newAnswer = '' + component.onNewAnswerValueChange() + expect(component.isAnswerEmpty).toBe(true) + }) + + it('should set isAnswerEmpty to false if new answer is not empty', () => { + component.newAnswer = 'This is a new answer' + component.onNewAnswerValueChange() + expect(component.isAnswerEmpty).toBe(false) + }) + }) +}) diff --git a/libs/ui/elements/src/lib/user-feedback-item/user-feedback-item.component.stories.ts b/libs/ui/elements/src/lib/user-feedback-item/user-feedback-item.component.stories.ts new file mode 100644 index 0000000000..a2803a7a04 --- /dev/null +++ b/libs/ui/elements/src/lib/user-feedback-item/user-feedback-item.component.stories.ts @@ -0,0 +1,99 @@ +import { + applicationConfig, + componentWrapperDecorator, + moduleMetadata, + StoryObj, +} from '@storybook/angular' +import { CommonModule } from '@angular/common' +import { UserFeedbackItemComponent } from './user-feedback-item.component' +import { TranslateModule } from '@ngx-translate/core' +import { + TRANSLATE_DEFAULT_CONFIG, + UtilI18nModule, +} from '@geonetwork-ui/util/i18n' +import { TimeSincePipe } from './time-since.pipe' +import { HttpClientModule } from '@angular/common/http' +import { importProvidersFrom } from '@angular/core' +import { ButtonComponent, TextAreaComponent } from '@geonetwork-ui/ui/inputs' +import { SpinningLoaderComponent } from '@geonetwork-ui/ui/widgets' + +export default { + title: 'Elements/UserFeedbackItemComponent', + component: UserFeedbackItemComponent, + decorators: [ + moduleMetadata({ + declarations: [UserFeedbackItemComponent, SpinningLoaderComponent], + imports: [ + CommonModule, + UtilI18nModule, + TranslateModule.forRoot(TRANSLATE_DEFAULT_CONFIG), + TimeSincePipe, + TextAreaComponent, + ButtonComponent, + ], + }), + applicationConfig({ + providers: [importProvidersFrom(HttpClientModule)], + }), + componentWrapperDecorator( + (story) => `
${story}
` + ), + ], +} +export const Primary: StoryObj = { + args: { + userFeedbackParent: { + published: true, + avatarUrl: + 'https://hips.hearstapps.com/hmg-prod/images/red-small-german-spitz-walking-in-the-autumn-park-royalty-free-image-1580496879.jpg?crop=0.670xw:1.00xh;0.173xw,0&resize=75:*', + authorName: 'John Doe', + date: new Date('2024-01-01T00:00:00Z'), + comment: 'Sample comment', + parentUuid: null, + uuid: '1', + metadataUUID: '', + authorUserId: '', + authorEmail: '', + }, + userFeedBacksAnswers: [ + { + published: true, + avatarUrl: + 'https://wp.inews.co.uk/wp-content/uploads/2019/06/Papageitaucher_Fratercula_arctica-1.jpg?resize=67,67&strip=all&quality=90', + authorName: 'Maria Carmen', + date: new Date('2024-03-30T00:00:00Z'), + comment: 'Sample answer number one', + parentUuid: '1', + uuid: '', + metadataUUID: '', + authorUserId: '', + authorEmail: '', + }, + { + published: true, + avatarUrl: + 'https://resize.prod.femina.ladmedia.fr/rblr/80,80/img/var/2023-07/mourir-peut-attendre.jpg', + authorName: 'James Bond', + date: new Date('2024-04-18T00:00:00Z'), + comment: 'Sample answer number two', + parentUuid: '1', + uuid: '', + metadataUUID: '', + authorUserId: '', + authorEmail: '', + }, + ], + isActiveUserEditor: true, + activeUser: { + id: '1', + email: 'john@example.com', + name: 'John', + surname: 'Doe', + profile: 'ADMIN', + username: '', + organisation: '', + }, + isLastComment: true, + isAddUserFeedbackLoading: false, + }, +} diff --git a/libs/ui/elements/src/lib/user-feedback-item/user-feedback-item.component.ts b/libs/ui/elements/src/lib/user-feedback-item/user-feedback-item.component.ts new file mode 100644 index 0000000000..1f1e50238b --- /dev/null +++ b/libs/ui/elements/src/lib/user-feedback-item/user-feedback-item.component.ts @@ -0,0 +1,63 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core' +import { + UserFeedback, + UserFeedbackViewModel, +} from '@geonetwork-ui/common/domain/model/record' +import { UserModel } from '@geonetwork-ui/common/domain/model/user' + +@Component({ + selector: 'gn-ui-user-feedback-item', + templateUrl: './user-feedback-item.component.html', + styleUrls: ['./user-feedback-item.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UserFeedbackItemComponent implements OnInit { + @Input() userFeedbackParent: UserFeedbackViewModel + @Input() userFeedBacksAnswers: UserFeedbackViewModel[] + @Input() isActiveUserEditor: boolean + @Input() activeUser: UserModel + @Input() isLastComment: boolean + @Input() isAddUserFeedbackLoading: boolean + + @Output() newUserFeedbackAnswer = new EventEmitter() + + isAnAnswer = false + newAnswer = '' + isAnswerEmpty = true + + ngOnInit(): void { + this.isAnAnswer = !!this.userFeedbackParent.parentUuid + } + + onNewAnswerValueChange() { + this.isAnswerEmpty = this.newAnswer.length === 0 + } + + publishNewAnswer() { + if (this.newAnswer.trim() === '') return + + const newAnswer: UserFeedback = { + ...this.userFeedbackParent, + uuid: undefined, + published: true, + comment: this.newAnswer, + parentUuid: this.userFeedbackParent.uuid, + authorUserId: this.activeUser?.id, + authorEmail: this.activeUser?.email, + date: new Date(), + authorName: `${this.activeUser?.name} ${this.activeUser?.surname}`, + } + + this.newUserFeedbackAnswer.emit(newAnswer) + + this.newAnswer = '' + this.onNewAnswerValueChange() + } +} diff --git a/libs/ui/inputs/src/lib/text-area/text-area.component.html b/libs/ui/inputs/src/lib/text-area/text-area.component.html index a1fdbf4de0..8e7e86164d 100644 --- a/libs/ui/inputs/src/lib/text-area/text-area.component.html +++ b/libs/ui/inputs/src/lib/text-area/text-area.component.html @@ -2,11 +2,12 @@
diff --git a/libs/ui/inputs/src/lib/text-area/text-area.component.ts b/libs/ui/inputs/src/lib/text-area/text-area.component.ts index 758bf7e53b..a6c25f7357 100644 --- a/libs/ui/inputs/src/lib/text-area/text-area.component.ts +++ b/libs/ui/inputs/src/lib/text-area/text-area.component.ts @@ -15,7 +15,12 @@ import { distinctUntilChanged } from 'rxjs/operators' standalone: true, }) export class TextAreaComponent implements AfterViewInit { + private readonly baseClasses: string + private readonly disabledClasses: string + @Input() value = '' + @Input() disabled = false + @Input() extraClass = '' @Input() placeholder: string @Input() required = false @@ -24,6 +29,30 @@ export class TextAreaComponent implements AfterViewInit { @ViewChild('input') input + constructor() { + this.baseClasses = [ + 'w-full', + 'pt-2', + 'pl-2', + 'resize-none', + 'border', + 'border-gray-800', + 'rounded italic', + 'leading-tight', + 'focus:outline-none', + 'focus:bg-background', + 'focus:border-primary', + ].join(' ') + + this.disabledClasses = ['cursor-not-allowed'].join(' ') + } + + get classList() { + return `${this.baseClasses} ${this.extraClass} ${ + this.disabled ? this.disabledClasses : '' + }` + } + ngAfterViewInit() { this.checkRequired(this.input.nativeElement.value) } diff --git a/libs/ui/inputs/src/lib/text-input/text-input.component.html b/libs/ui/inputs/src/lib/text-input/text-input.component.html index ee459de3f8..df92d6bbb3 100644 --- a/libs/ui/inputs/src/lib/text-input/text-input.component.html +++ b/libs/ui/inputs/src/lib/text-input/text-input.component.html @@ -1,6 +1,6 @@ () @Output() valueChange = this.rawChange.pipe(distinctUntilChanged()) - @ViewChild('input') input + get classList() { + return `${this.baseClass} ${this.extraClass}` + } + ngAfterViewInit() { this.checkRequired(this.input.nativeElement.value) } diff --git a/package-lock.json b/package-lock.json index 91d3460244..27b0b00c67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "basiclightbox": "^5.0.4", "chart.js": "^4.2.0", "chroma-js": "^2.1.2", - "date-fns": "^2.29.3", + "date-fns": "^2.30.0", "document-register-element": "^1.14.10", "duration-relativetimeformat": "^2.0.3", "embla-carousel": "^8.0.0-rc14", @@ -103,6 +103,7 @@ "@storybook/addon-essentials": "7.2.1", "@storybook/angular": "7.2.1", "@types/chroma-js": "^2.1.3", + "@types/date-fns": "^2.6.0", "@types/express": "^4.17.12", "@types/geojson": "^7946.0.7", "@types/jest": "^29.4.0", @@ -4489,15 +4490,15 @@ } }, "node_modules/@jest/console": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.6.2.tgz", - "integrity": "sha512-0N0yZof5hi44HAR2pPS+ikJ3nzKNoZdVu8FffRf3wy47I7Dm7etk/3KetMdRUqzVd16V4O2m2ISpNTbnIuqy1w==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", "dependencies": { - "@jest/types": "^29.6.1", + "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", - "jest-message-util": "^29.6.2", - "jest-util": "^29.6.2", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", "slash": "^3.0.0" }, "engines": { @@ -4652,67 +4653,67 @@ } }, "node_modules/@jest/environment": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.2.tgz", - "integrity": "sha512-AEcW43C7huGd/vogTddNNTDRpO6vQ2zaQNrttvWV18ArBx9Z56h7BIsXkNFJVOO4/kblWEQz30ckw0+L3izc+Q==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", "dependencies": { - "@jest/fake-timers": "^29.6.2", - "@jest/types": "^29.6.1", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", "@types/node": "*", - "jest-mock": "^29.6.2" + "jest-mock": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/expect": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.6.2.tgz", - "integrity": "sha512-m6DrEJxVKjkELTVAztTLyS/7C92Y2b0VYqmDROYKLLALHn8T/04yPs70NADUYPrV3ruI+H3J0iUIuhkjp7vkfg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", "dependencies": { - "expect": "^29.6.2", - "jest-snapshot": "^29.6.2" + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.6.2.tgz", - "integrity": "sha512-6zIhM8go3RV2IG4aIZaZbxwpOzz3ZiM23oxAlkquOIole+G6TrbeXnykxWYlqF7kz2HlBjdKtca20x9atkEQYg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", "dependencies": { - "jest-get-type": "^29.4.3" + "jest-get-type": "^29.6.3" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/fake-timers": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.6.2.tgz", - "integrity": "sha512-euZDmIlWjm1Z0lJ1D0f7a0/y5Kh/koLFMUBE5SUYWrmy8oNhJpbTBDAP6CxKnadcMLDoDf4waRYCe35cH6G6PA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", "dependencies": { - "@jest/types": "^29.6.1", + "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", - "jest-message-util": "^29.6.2", - "jest-mock": "^29.6.2", - "jest-util": "^29.6.2" + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/globals": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.6.2.tgz", - "integrity": "sha512-cjuJmNDjs6aMijCmSa1g2TNG4Lby/AeU7/02VtpW+SLcZXzOLK2GpN2nLqcFjmhy3B3AoPeQVx7BnyOf681bAw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", "dependencies": { - "@jest/environment": "^29.6.2", - "@jest/expect": "^29.6.2", - "@jest/types": "^29.6.1", - "jest-mock": "^29.6.2" + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -4809,9 +4810,9 @@ } }, "node_modules/@jest/schemas": { - "version": "29.6.0", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.0.tgz", - "integrity": "sha512-rxLjXyJBTL4LQeJW3aKo0M/+GkCOXsO+8i9Iu7eDb6KwtP65ayoDsitrdPBtujxQ88k4wI2FNYfa6TOGwSn6cQ==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -4820,9 +4821,9 @@ } }, "node_modules/@jest/source-map": { - "version": "29.6.0", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.0.tgz", - "integrity": "sha512-oA+I2SHHQGxDCZpbrsCQSoMLb3Bz547JnM+jUr9qEbuw0vQlWZfpPS7CO9J7XiwKicEz9OFn/IYoLkkiUD7bzA==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", "dependencies": { "@jridgewell/trace-mapping": "^0.3.18", "callsites": "^3.0.0", @@ -4833,12 +4834,12 @@ } }, "node_modules/@jest/test-result": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.6.2.tgz", - "integrity": "sha512-3VKFXzcV42EYhMCsJQURptSqnyjqCGbtLuX5Xxb6Pm6gUf1wIRIl+mandIRGJyWKgNKYF9cnstti6Ls5ekduqw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", "dependencies": { - "@jest/console": "^29.6.2", - "@jest/types": "^29.6.1", + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "collect-v8-coverage": "^1.0.0" }, @@ -4861,21 +4862,21 @@ } }, "node_modules/@jest/transform": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.6.2.tgz", - "integrity": "sha512-ZqCqEISr58Ce3U+buNFJYUktLJZOggfyvR+bZMaiV1e8B1SIvJbwZMrYz3gx/KAPn9EXmOmN+uB08yLCjWkQQg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", "dependencies": { "@babel/core": "^7.11.6", - "@jest/types": "^29.6.1", + "@jest/types": "^29.6.3", "@jridgewell/trace-mapping": "^0.3.18", "babel-plugin-istanbul": "^6.1.1", "chalk": "^4.0.0", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.6.2", - "jest-regex-util": "^29.4.3", - "jest-util": "^29.6.2", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", "micromatch": "^4.0.4", "pirates": "^4.0.4", "slash": "^3.0.0", @@ -4939,11 +4940,11 @@ } }, "node_modules/@jest/types": { - "version": "29.6.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.1.tgz", - "integrity": "sha512-tPKQNMPuXgvdOn2/Lg9HNfUvjYVGolt04Hp03f5hAk878uwOLikN+JzeLY0HcVgKgFl9Hs3EIqpu3WX27XNhnw==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "dependencies": { - "@jest/schemas": "^29.6.0", + "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", @@ -12246,6 +12247,16 @@ "@types/node": "*" } }, + "node_modules/@types/date-fns": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@types/date-fns/-/date-fns-2.6.0.tgz", + "integrity": "sha512-9DSw2ZRzV0Tmpa6PHHJbMcZn79HHus+BBBohcOaDzkK/G3zMjDUDYjJIWBFLbkh+1+/IOS0A59BpQfdr37hASg==", + "deprecated": "This is a stub types definition for date-fns (https://github.com/date-fns/date-fns). date-fns provides its own type definitions, so you don't need @types/date-fns installed!", + "dev": true, + "dependencies": { + "date-fns": "*" + } + }, "node_modules/@types/detect-port": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@types/detect-port/-/detect-port-1.3.3.tgz", @@ -12376,9 +12387,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.3", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.3.tgz", - "integrity": "sha512-1Nq7YrO/vJE/FYnqYyw0FS8LdrjExSgIiHyKg7xPpn+yi8Q4huZryKnkJatN1ZRH89Kw2v33/8ZMB7DuZeSLlA==", + "version": "29.5.12", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz", + "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==", "dev": true, "dependencies": { "expect": "^29.0.0", @@ -17631,9 +17642,9 @@ } }, "node_modules/diff-sequences": { - "version": "29.4.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.4.3.tgz", - "integrity": "sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -18627,16 +18638,15 @@ } }, "node_modules/expect": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.6.2.tgz", - "integrity": "sha512-iAErsLxJ8C+S02QbLAwgSGSezLQK+XXRDt8IuFXFpwCNw2ECmzZSmjKcCaFVp5VRMk+WAvz6h6jokzEzBFZEuA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", "dependencies": { - "@jest/expect-utils": "^29.6.2", - "@types/node": "*", - "jest-get-type": "^29.4.3", - "jest-matcher-utils": "^29.6.2", - "jest-message-util": "^29.6.2", - "jest-util": "^29.6.2" + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -22102,14 +22112,14 @@ } }, "node_modules/jest-diff": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.6.2.tgz", - "integrity": "sha512-t+ST7CB9GX5F2xKwhwCf0TAR17uNDiaPTZnVymP9lw0lssa9vG+AFyDZoeIHStU3WowFFwT+ky+er0WVl2yGhA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", "dependencies": { "chalk": "^4.0.0", - "diff-sequences": "^29.4.3", - "jest-get-type": "^29.4.3", - "pretty-format": "^29.6.2" + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -22175,15 +22185,15 @@ } }, "node_modules/jest-each": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.6.2.tgz", - "integrity": "sha512-MsrsqA0Ia99cIpABBc3izS1ZYoYfhIy0NNWqPSE0YXbQjwchyt6B1HD2khzyPe1WiJA7hbxXy77ZoUQxn8UlSw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", "dependencies": { - "@jest/types": "^29.6.1", + "@jest/types": "^29.6.3", "chalk": "^4.0.0", - "jest-get-type": "^29.4.3", - "jest-util": "^29.6.2", - "pretty-format": "^29.6.2" + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -22397,27 +22407,27 @@ } }, "node_modules/jest-get-type": { - "version": "29.4.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.4.3.tgz", - "integrity": "sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-haste-map": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.6.2.tgz", - "integrity": "sha512-+51XleTDAAysvU8rT6AnS1ZJ+WHVNqhj1k6nTvN2PYP+HjU3kqlaKQ1Lnw3NYW3bm2r8vq82X0Z1nDDHZMzHVA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", "dependencies": { - "@jest/types": "^29.6.1", + "@jest/types": "^29.6.3", "@types/graceful-fs": "^4.1.3", "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.4.3", - "jest-util": "^29.6.2", - "jest-worker": "^29.6.2", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "walker": "^1.0.8" }, @@ -22441,14 +22451,14 @@ } }, "node_modules/jest-matcher-utils": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.6.2.tgz", - "integrity": "sha512-4LiAk3hSSobtomeIAzFTe+N8kL6z0JtF3n6I4fg29iIW7tt99R7ZcIFW34QkX+DuVrf+CUe6wuVOpm7ZKFJzZQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", "dependencies": { "chalk": "^4.0.0", - "jest-diff": "^29.6.2", - "jest-get-type": "^29.4.3", - "pretty-format": "^29.6.2" + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -22503,17 +22513,17 @@ } }, "node_modules/jest-message-util": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.6.2.tgz", - "integrity": "sha512-vnIGYEjoPSuRqV8W9t+Wow95SDp6KPX2Uf7EoeG9G99J2OVh7OSwpS4B6J0NfpEIpfkBNHlBZpA2rblEuEFhZQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", "dependencies": { "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.1", + "@jest/types": "^29.6.3", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "micromatch": "^4.0.4", - "pretty-format": "^29.6.2", + "pretty-format": "^29.7.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" }, @@ -22570,13 +22580,13 @@ } }, "node_modules/jest-mock": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.2.tgz", - "integrity": "sha512-hoSv3lb3byzdKfwqCuT6uTscan471GUECqgNYykg6ob0yiAw3zYc7OrPnI9Qv8Wwoa4lC7AZ9hyS4AiIx5U2zg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", "dependencies": { - "@jest/types": "^29.6.1", + "@jest/types": "^29.6.3", "@types/node": "*", - "jest-util": "^29.6.2" + "jest-util": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -22627,24 +22637,24 @@ } }, "node_modules/jest-regex-util": { - "version": "29.4.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.4.3.tgz", - "integrity": "sha512-O4FglZaMmWXbGHSQInfXewIsd1LMn9p3ZXB/6r4FOkyhX2/iP/soMG98jGvk/A3HAN78+5VWcBGO0BJAPRh4kg==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-resolve": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.6.2.tgz", - "integrity": "sha512-G/iQUvZWI5e3SMFssc4ug4dH0aZiZpsDq9o1PtXTV1210Ztyb2+w+ZgQkB3iOiC5SmAEzJBOHWz6Hvrd+QnNPw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", "dependencies": { "chalk": "^4.0.0", "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.6.2", + "jest-haste-map": "^29.7.0", "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.6.2", - "jest-validate": "^29.6.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", "resolve": "^1.20.0", "resolve.exports": "^2.0.0", "slash": "^3.0.0" @@ -22819,30 +22829,30 @@ } }, "node_modules/jest-runtime": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.6.2.tgz", - "integrity": "sha512-2X9dqK768KufGJyIeLmIzToDmsN0m7Iek8QNxRSI/2+iPFYHF0jTwlO3ftn7gdKd98G/VQw9XJCk77rbTGZnJg==", - "dependencies": { - "@jest/environment": "^29.6.2", - "@jest/fake-timers": "^29.6.2", - "@jest/globals": "^29.6.2", - "@jest/source-map": "^29.6.0", - "@jest/test-result": "^29.6.2", - "@jest/transform": "^29.6.2", - "@jest/types": "^29.6.1", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "cjs-module-lexer": "^1.0.0", "collect-v8-coverage": "^1.0.0", "glob": "^7.1.3", "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.6.2", - "jest-message-util": "^29.6.2", - "jest-mock": "^29.6.2", - "jest-regex-util": "^29.4.3", - "jest-resolve": "^29.6.2", - "jest-snapshot": "^29.6.2", - "jest-util": "^29.6.2", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -22899,29 +22909,29 @@ } }, "node_modules/jest-snapshot": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.6.2.tgz", - "integrity": "sha512-1OdjqvqmRdGNvWXr/YZHuyhh5DeaLp1p/F8Tht/MrMw4Kr1Uu/j4lRG+iKl1DAqUJDWxtQBMk41Lnf/JETYBRA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", "dependencies": { "@babel/core": "^7.11.6", "@babel/generator": "^7.7.2", "@babel/plugin-syntax-jsx": "^7.7.2", "@babel/plugin-syntax-typescript": "^7.7.2", "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.6.2", - "@jest/transform": "^29.6.2", - "@jest/types": "^29.6.1", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0", "chalk": "^4.0.0", - "expect": "^29.6.2", + "expect": "^29.7.0", "graceful-fs": "^4.2.9", - "jest-diff": "^29.6.2", - "jest-get-type": "^29.4.3", - "jest-matcher-utils": "^29.6.2", - "jest-message-util": "^29.6.2", - "jest-util": "^29.6.2", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", "natural-compare": "^1.4.0", - "pretty-format": "^29.6.2", + "pretty-format": "^29.7.0", "semver": "^7.5.3" }, "engines": { @@ -22977,11 +22987,11 @@ } }, "node_modules/jest-util": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.6.2.tgz", - "integrity": "sha512-3eX1qb6L88lJNCFlEADKOkjpXJQyZRiavX1INZ4tRnrBVr2COd3RgcTLyUiEXMNBlDU/cgYq6taUS0fExrWW4w==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "dependencies": { - "@jest/types": "^29.6.1", + "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", "ci-info": "^3.2.0", @@ -23041,16 +23051,16 @@ } }, "node_modules/jest-validate": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.6.2.tgz", - "integrity": "sha512-vGz0yMN5fUFRRbpJDPwxMpgSXW1LDKROHfBopAvDcmD6s+B/s8WJrwi+4bfH4SdInBA5C3P3BI19dBtKzx1Arg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", "dependencies": { - "@jest/types": "^29.6.1", + "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", - "jest-get-type": "^29.4.3", + "jest-get-type": "^29.6.3", "leven": "^3.1.0", - "pretty-format": "^29.6.2" + "pretty-format": "^29.7.0" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -23182,12 +23192,12 @@ } }, "node_modules/jest-worker": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.6.2.tgz", - "integrity": "sha512-l3ccBOabTdkng8I/ORCkADz4eSMKejTYv1vB/Z83UiubqhC1oQ5Li6dWCyqOIvSifGjUBxuvxvlm6KGK2DtuAQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "dependencies": { "@types/node": "*", - "jest-util": "^29.6.2", + "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" }, @@ -27744,11 +27754,11 @@ } }, "node_modules/pretty-format": { - "version": "29.6.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.6.2.tgz", - "integrity": "sha512-1q0oC8eRveTg5nnBEWMXAU2qpv65Gnuf2eCQzSjxpWFkPaPARwqZZDGuNE0zPAZfTCHzIk3A8dIjwlQKKLphyg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dependencies": { - "@jest/schemas": "^29.6.0", + "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" }, diff --git a/package.json b/package.json index c47e4b6396..dabc795ef1 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "basiclightbox": "^5.0.4", "chart.js": "^4.2.0", "chroma-js": "^2.1.2", - "date-fns": "^2.29.3", + "date-fns": "^2.30.0", "document-register-element": "^1.14.10", "duration-relativetimeformat": "^2.0.3", "embla-carousel": "^8.0.0-rc14", @@ -138,6 +138,7 @@ "@storybook/addon-essentials": "7.2.1", "@storybook/angular": "7.2.1", "@types/chroma-js": "^2.1.3", + "@types/date-fns": "^2.6.0", "@types/express": "^4.17.12", "@types/geojson": "^7946.0.7", "@types/jest": "^29.4.0", diff --git a/support-services/docker-entrypoint-initdb.d/dump b/support-services/docker-entrypoint-initdb.d/dump index 7bef8d3978..2778ff3395 100644 Binary files a/support-services/docker-entrypoint-initdb.d/dump and b/support-services/docker-entrypoint-initdb.d/dump differ diff --git a/translations/de.json b/translations/de.json index a1bd4ead9d..62b229bf04 100644 --- a/translations/de.json +++ b/translations/de.json @@ -1,4 +1,5 @@ { + "button.login": "", "catalog.figures.datasets": "{count, plural, =0{Datensätze} one{Datensatz} other{Datensätze}}", "catalog.figures.organisations": "{count, plural, =0{Organisationen} one{Organisation} other{Organisationen}}", "chart.aggregation.average": "Durchschnitt", @@ -278,6 +279,14 @@ "record.metadata.quality.updateFrequency.failed": "Aktualisierungsfrequenz nicht angegeben", "record.metadata.quality.updateFrequency.success": "Aktualisierungsfrequenz angegeben", "record.metadata.related": "Ähnliche Datensätze", + "record.metadata.userFeedbacks": "", + "record.metadata.userFeedbacks.anonymousUser": "", + "record.metadata.userFeedbacks.sortSelector.label": "", + "record.metadata.userFeedbacks.sortSelector.choices.newestFirst": "", + "record.metadata.userFeedbacks.sortSelector.choices.oldestFirst": "", + "record.metadata.userFeedbacks.newComment.placeholder": "", + "record.metadata.userFeedbacks.newAnswer.placeholder": "", + "record.metadata.userFeedbacks.newAnswer.buttonTitle": "", "record.metadata.sheet": "Weitere Informationen verfügbar unter:", "record.metadata.status": "Status", "record.metadata.technical": "Technische Informationen", @@ -351,6 +360,12 @@ "table.loading.data": "Daten werden geladen...", "table.object.count": "Objekte in diesem Datensatz", "table.select.data": "Datenquelle", + "timeSincePipe.lessThanAMinute": "", + "timeSincePipe.minutesAgo": "", + "timeSincePipe.hoursAgo": "", + "timeSincePipe.daysAgo": "", + "timeSincePipe.monthsAgo": "", + "timeSincePipe.yearsAgo": "", "tooltip.html.copy": "HTML kopieren", "tooltip.id.copy": "Eindeutige Kennung kopieren", "tooltip.url.copy": "URL kopieren", diff --git a/translations/en.json b/translations/en.json index c633da1aec..64db73f843 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1,4 +1,5 @@ { + "button.login": "Log in", "catalog.figures.datasets": "{count, plural, =0{datasets} one{dataset} other{datasets}}", "catalog.figures.organisations": "{count, plural, =0{organisations} one{organisation} other{organisations}}", "chart.aggregation.average": "average", @@ -278,6 +279,14 @@ "record.metadata.quality.updateFrequency.failed": "Update frequency is not specified", "record.metadata.quality.updateFrequency.success": "Update frequency is specified", "record.metadata.related": "Related records", + "record.metadata.userFeedbacks": "Questions / Answers", + "record.metadata.userFeedbacks.anonymousUser": "In order to leave a comment, please log in.", + "record.metadata.userFeedbacks.sortSelector.label": "Sort by ...", + "record.metadata.userFeedbacks.sortSelector.choices.newestFirst": "Newest comments first", + "record.metadata.userFeedbacks.sortSelector.choices.oldestFirst": "Oldest comments first", + "record.metadata.userFeedbacks.newComment.placeholder": "Write your comment here...", + "record.metadata.userFeedbacks.newAnswer.placeholder": "Answer...", + "record.metadata.userFeedbacks.newAnswer.buttonTitle": "Publish", "record.metadata.sheet": "Original metadata", "record.metadata.status": "Status", "record.metadata.technical": "Technical information", @@ -351,6 +360,12 @@ "table.loading.data": "Loading data...", "table.object.count": "objects in this dataset", "table.select.data": "Data source", + "timeSincePipe.lessThanAMinute": "Less than a minute ago", + "timeSincePipe.minutesAgo": "{value} minute{s} ago", + "timeSincePipe.hoursAgo": "{value} hour{s} ago", + "timeSincePipe.daysAgo": "{value} day{s} ago", + "timeSincePipe.monthsAgo": "{value} month{s} ago", + "timeSincePipe.yearsAgo": "{value} year{s} ago", "tooltip.html.copy": "Copy HTML", "tooltip.id.copy": "Copy unique identifier", "tooltip.url.copy": "Copy URL", diff --git a/translations/es.json b/translations/es.json index d5fa70dbae..9eef1a70ac 100644 --- a/translations/es.json +++ b/translations/es.json @@ -1,4 +1,5 @@ { + "button.login": "", "catalog.figures.datasets": "conjuntos de datos", "catalog.figures.organisations": "organizaciones", "chart.aggregation.average": "promedio", @@ -278,6 +279,14 @@ "record.metadata.quality.updateFrequency.failed": "", "record.metadata.quality.updateFrequency.success": "", "record.metadata.related": "", + "record.metadata.userFeedbacks": "", + "record.metadata.userFeedbacks.anonymousUser": "", + "record.metadata.userFeedbacks.sortSelector.label": "", + "record.metadata.userFeedbacks.sortSelector.choices.newestFirst": "", + "record.metadata.userFeedbacks.sortSelector.choices.oldestFirst": "", + "record.metadata.userFeedbacks.newComment.placeholder": "", + "record.metadata.userFeedbacks.newAnswer.placeholder": "", + "record.metadata.userFeedbacks.newAnswer.buttonTitle": "", "record.metadata.sheet": "", "record.metadata.status": "", "record.metadata.technical": "", @@ -351,6 +360,12 @@ "table.loading.data": "", "table.object.count": "", "table.select.data": "", + "timeSincePipe.lessThanAMinute": "", + "timeSincePipe.minutesAgo": "", + "timeSincePipe.hoursAgo": "", + "timeSincePipe.daysAgo": "", + "timeSincePipe.monthsAgo": "", + "timeSincePipe.yearsAgo": "", "tooltip.html.copy": "", "tooltip.id.copy": "", "tooltip.url.copy": "", diff --git a/translations/fr.json b/translations/fr.json index cb3231a31a..6f3ba2a211 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -1,4 +1,5 @@ { + "button.login": "Se connecter", "catalog.figures.datasets": "{count, plural, =0{données} one{donnée} other{données}}", "catalog.figures.organisations": "{count, plural, =0{organisations} one{organisation} other{organisations}}", "chart.aggregation.average": "moyenne", @@ -278,6 +279,14 @@ "record.metadata.quality.updateFrequency.failed": "Fréquence de mise à jour n'est pas renseignée", "record.metadata.quality.updateFrequency.success": "Fréquence de mise à jour est renseignée", "record.metadata.related": "Voir aussi", + "record.metadata.userFeedbacks": "Questions / Réponses", + "record.metadata.userFeedbacks.anonymousUser": "Pour rédiger un commentaire, veuillez vous identifier.", + "record.metadata.userFeedbacks.sortSelector.label": "Trier par ...", + "record.metadata.userFeedbacks.sortSelector.choices.newestFirst": "Les plus récents en premier", + "record.metadata.userFeedbacks.sortSelector.choices.oldestFirst": "Les plus anciens en premier", + "record.metadata.userFeedbacks.newComment.placeholder": "Rédiger votre commentaire ici...", + "record.metadata.userFeedbacks.newAnswer.placeholder": "Répondre...", + "record.metadata.userFeedbacks.newAnswer.buttonTitle": "Publier", "record.metadata.sheet": "Fiche de métadonnées d'origine", "record.metadata.status": "Statut", "record.metadata.technical": "Informations techniques", @@ -351,6 +360,12 @@ "table.loading.data": "Chargement des données...", "table.object.count": "enregistrements dans ces données", "table.select.data": "Source de données", + "timeSincePipe.lessThanAMinute": "Il y a moins d'une minute", + "timeSincePipe.minutesAgo": "Il y a {value} minute{s}", + "timeSincePipe.hoursAgo": "Il y a {value} heure{s}", + "timeSincePipe.daysAgo": "Il y a {value} jour{s}", + "timeSincePipe.monthsAgo": "Il y a {value} mois", + "timeSincePipe.yearsAgo": "Il y a {value} an{s}", "tooltip.html.copy": "Copier le HTML", "tooltip.id.copy": "Copier l'identifiant unique", "tooltip.url.copy": "Copier l'URL", diff --git a/translations/it.json b/translations/it.json index ffee1b9e90..71cdd0bd42 100644 --- a/translations/it.json +++ b/translations/it.json @@ -1,4 +1,5 @@ { + "button.login": "", "catalog.figures.datasets": "{count, plural, =0{datasets} one{dataset} other{datasets}}", "catalog.figures.organisations": "{count, plural, =0{organizzazioni} one{organizzazione} other{organizzazioni}}", "chart.aggregation.average": "media", @@ -278,6 +279,14 @@ "record.metadata.quality.updateFrequency.failed": "La frequenza di aggiornamento non è specificata", "record.metadata.quality.updateFrequency.success": "La frequenza di aggiornamento è specificata", "record.metadata.related": "Vedi anche", + "record.metadata.userFeedbacks": "", + "record.metadata.userFeedbacks.anonymousUser": "", + "record.metadata.userFeedbacks.sortSelector.label": "", + "record.metadata.userFeedbacks.sortSelector.choices.newestFirst": "", + "record.metadata.userFeedbacks.sortSelector.choices.oldestFirst": "", + "record.metadata.userFeedbacks.newComment.placeholder": "", + "record.metadata.userFeedbacks.newAnswer.placeholder": "", + "record.metadata.userFeedbacks.newAnswer.buttonTitle": "", "record.metadata.sheet": "Origine del metadata", "record.metadata.status": "Stato", "record.metadata.technical": "Informazioni tecniche", @@ -351,6 +360,12 @@ "table.loading.data": "Caricamento dei dati...", "table.object.count": "record in questi dati", "table.select.data": "Sorgente dati", + "timeSincePipe.lessThanAMinute": "", + "timeSincePipe.minutesAgo": "", + "timeSincePipe.hoursAgo": "", + "timeSincePipe.daysAgo": "", + "timeSincePipe.monthsAgo": "", + "timeSincePipe.yearsAgo": "", "tooltip.html.copy": "Copiare il HTML", "tooltip.id.copy": "Copiare l'identificatore unico", "tooltip.url.copy": "Copiare l'URL", diff --git a/translations/nl.json b/translations/nl.json index 0e44dc0b00..d29ab911e8 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -1,4 +1,5 @@ { + "button.login": "", "catalog.figures.datasets": "datasets", "catalog.figures.organisations": "organisaties", "chart.aggregation.average": "gemiddelde", @@ -278,6 +279,14 @@ "record.metadata.quality.updateFrequency.failed": "", "record.metadata.quality.updateFrequency.success": "", "record.metadata.related": "", + "record.metadata.userFeedbacks": "", + "record.metadata.userFeedbacks.anonymousUser": "", + "record.metadata.userFeedbacks.sortSelector.label": "", + "record.metadata.userFeedbacks.sortSelector.choices.newestFirst": "", + "record.metadata.userFeedbacks.sortSelector.choices.oldestFirst": "", + "record.metadata.userFeedbacks.newComment.placeholder": "", + "record.metadata.userFeedbacks.newAnswer.placeholder": "", + "record.metadata.userFeedbacks.newAnswer.buttonTitle": "", "record.metadata.sheet": "", "record.metadata.status": "", "record.metadata.technical": "", @@ -351,6 +360,12 @@ "table.loading.data": "", "table.object.count": "", "table.select.data": "", + "timeSincePipe.lessThanAMinute": "", + "timeSincePipe.minutesAgo": "", + "timeSincePipe.hoursAgo": "", + "timeSincePipe.daysAgo": "", + "timeSincePipe.monthsAgo": "", + "timeSincePipe.yearsAgo": "", "tooltip.html.copy": "", "tooltip.id.copy": "", "tooltip.url.copy": "", diff --git a/translations/pt.json b/translations/pt.json index 6898e80589..7acbb1d8f1 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -1,4 +1,5 @@ { + "button.login": "", "catalog.figures.datasets": "conjuntos de dados", "catalog.figures.organisations": "organizações", "chart.aggregation.average": "média", @@ -278,6 +279,14 @@ "record.metadata.quality.updateFrequency.failed": "", "record.metadata.quality.updateFrequency.success": "", "record.metadata.related": "", + "record.metadata.userFeedbacks": "", + "record.metadata.userFeedbacks.anonymousUser": "", + "record.metadata.userFeedbacks.sortSelector.label": "", + "record.metadata.userFeedbacks.sortSelector.choices.newestFirst": "", + "record.metadata.userFeedbacks.sortSelector.choices.oldestFirst": "", + "record.metadata.userFeedbacks.newComment.placeholder": "", + "record.metadata.userFeedbacks.newAnswer.placeholder": "", + "record.metadata.userFeedbacks.newAnswer.buttonTitle": "", "record.metadata.sheet": "", "record.metadata.status": "", "record.metadata.technical": "", @@ -351,6 +360,12 @@ "table.loading.data": "", "table.object.count": "", "table.select.data": "", + "timeSincePipe.lessThanAMinute": "", + "timeSincePipe.minutesAgo": "", + "timeSincePipe.hoursAgo": "", + "timeSincePipe.daysAgo": "", + "timeSincePipe.monthsAgo": "", + "timeSincePipe.yearsAgo": "", "tooltip.html.copy": "", "tooltip.id.copy": "", "tooltip.url.copy": "", diff --git a/translations/sk.json b/translations/sk.json index 35dc635932..9a6bf1cc0d 100644 --- a/translations/sk.json +++ b/translations/sk.json @@ -1,4 +1,5 @@ { + "button.login": "", "catalog.figures.datasets": "{count, plural, =0{datasety} one{dataset} other{datasety}}", "catalog.figures.organisations": "{count, plural, =0{organizácie} one{organizácia} other{organizácie}}", "chart.aggregation.average": "priemer", @@ -278,6 +279,14 @@ "record.metadata.quality.updateFrequency.failed": "Frekvencia aktualizácie nie je určená", "record.metadata.quality.updateFrequency.success": "Frekvencia aktualizácie je určená", "record.metadata.related": "Súvisiace záznamy", + "record.metadata.userFeedbacks": "", + "record.metadata.userFeedbacks.anonymousUser": "", + "record.metadata.userFeedbacks.sortSelector.label": "", + "record.metadata.userFeedbacks.sortSelector.choices.newestFirst": "", + "record.metadata.userFeedbacks.sortSelector.choices.oldestFirst": "", + "record.metadata.userFeedbacks.newComment.placeholder": "", + "record.metadata.userFeedbacks.newAnswer.placeholder": "", + "record.metadata.userFeedbacks.newAnswer.buttonTitle": "", "record.metadata.sheet": "Ďalšie metadáta sú k dispozícii na:", "record.metadata.status": "Stav", "record.metadata.technical": "", @@ -351,6 +360,12 @@ "table.loading.data": "Načítanie údajov...", "table.object.count": "objekty v tomto súbore údajov", "table.select.data": "Zdroj údajov", + "timeSincePipe.lessThanAMinute": "", + "timeSincePipe.minutesAgo": "", + "timeSincePipe.hoursAgo": "", + "timeSincePipe.daysAgo": "", + "timeSincePipe.monthsAgo": "", + "timeSincePipe.yearsAgo": "", "tooltip.html.copy": "Kopírovať HTML", "tooltip.id.copy": "Kopírovať jedinečný identifikátor", "tooltip.url.copy": "Kopírovať URL",