diff --git a/.github/workflows/artifacts.yml b/.github/workflows/artifacts.yml index d3dd650b69..d2ed5219e3 100644 --- a/.github/workflows/artifacts.yml +++ b/.github/workflows/artifacts.yml @@ -7,6 +7,7 @@ on: push: branches: - main + - develop release: types: [published] issue_comment: @@ -20,7 +21,7 @@ concurrency: env: NODE_VERSION: 18.16.1 # a list of apps to build and publish on releases - APP_NAMES: datafeeder,datahub + APP_NAMES: datafeeder,datahub,metadata-editor jobs: checks: diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index 0d9c50c9b3..c11079c823 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -3,7 +3,7 @@ run-name: 🧹 Cleanup operations for 🌱 ${{github.event.ref}} env: # a list of apps to build and publish on releases - APP_NAMES: datafeeder,datahub + APP_NAMES: datafeeder,datahub,metadata-editor on: delete: diff --git a/apps/datahub/src/app/home/search/search-filters/search-filters.component.html b/apps/datahub/src/app/home/search/search-filters/search-filters.component.html index e4f7b3eed9..1becddb161 100644 --- a/apps/datahub/src/app/home/search/search-filters/search-filters.component.html +++ b/apps/datahub/src/app/home/search/search-filters/search-filters.component.html @@ -53,7 +53,7 @@

>
(toggled)="toggleSpatialFilter($event)" >
+
+ +
+
+ + {{ 'search.filters.otherRecords' | translate }} + close + +
({ getOptionalSearchConfig: () => ({ @@ -67,6 +66,7 @@ export class MockFilterDropdownComponent { @Input() title: string } const state = { OrgForResource: { mel: true } } as FieldFilters +const user = USER_FIXTURE() class SearchFacadeMock { searchFilters$ = new BehaviorSubject(state) hasSpatialFilter$ = new BehaviorSubject(false) @@ -103,6 +103,12 @@ class FieldsServiceMock { } } +class AuthServiceMock { + user$ = new BehaviorSubject(user) + authReady = jest.fn(() => this._authSubject$) + _authSubject$ = new BehaviorSubject({}) +} + describe('SearchFiltersComponent', () => { let component: SearchFiltersComponent let fixture: ComponentFixture @@ -131,6 +137,10 @@ describe('SearchFiltersComponent', () => { provide: FieldsService, useClass: FieldsServiceMock, }, + { + provide: AuthService, + useClass: AuthServiceMock, + }, ], }) .overrideComponent(SearchFiltersComponent, { @@ -155,7 +165,9 @@ describe('SearchFiltersComponent', () => { describe('spatial filter button', () => { function getCheckToggleDebugElement() { - return fixture.debugElement.query(By.directive(MockCheckToggleComponent)) + return fixture.debugElement.queryAll( + By.directive(MockCheckToggleComponent) + ) } describe('when panel is closed', () => { @@ -164,7 +176,7 @@ describe('SearchFiltersComponent', () => { fixture.detectChanges() }) it('does not show up', () => { - expect(getCheckToggleDebugElement()).toBeFalsy() + expect(getCheckToggleDebugElement().length).toBeFalsy() }) }) describe('when panel is opened & a spatial filter is unavailable', () => { @@ -174,7 +186,7 @@ describe('SearchFiltersComponent', () => { fixture.detectChanges() }) it('does not show up', () => { - expect(getCheckToggleDebugElement()).toBeFalsy() + expect(getCheckToggleDebugElement().length).toBe(1) }) }) describe('when panel is opened & a spatial filter is available', () => { @@ -185,11 +197,11 @@ describe('SearchFiltersComponent', () => { fixture.detectChanges() }) it('does show up', () => { - expect(getCheckToggleDebugElement()).toBeTruthy() + expect(getCheckToggleDebugElement().length).toBe(2) }) it('has the value set in the state', () => { expect( - getCheckToggleDebugElement().componentInstance.value + getCheckToggleDebugElement()[0].componentInstance.value ).toBeTruthy() }) }) @@ -201,7 +213,7 @@ describe('SearchFiltersComponent', () => { }) it('emits a SetSpatialFilterEnabled action', () => { const checkToggleComponent = - getCheckToggleDebugElement().componentInstance + getCheckToggleDebugElement()[0].componentInstance checkToggleComponent.toggled.emit(false) expect(searchFacade.setSpatialFilterEnabled).toHaveBeenCalledWith(false) }) diff --git a/apps/datahub/src/app/home/search/search-filters/search-filters.component.ts b/apps/datahub/src/app/home/search/search-filters/search-filters.component.ts index f28155e0db..77c87748f2 100644 --- a/apps/datahub/src/app/home/search/search-filters/search-filters.component.ts +++ b/apps/datahub/src/app/home/search/search-filters/search-filters.component.ts @@ -5,6 +5,7 @@ import { QueryList, ViewChildren, } from '@angular/core' +import { AuthService } from '@geonetwork-ui/api/repository/gn4' import { FieldsService, FilterDropdownComponent, @@ -12,6 +13,8 @@ import { SearchService, } from '@geonetwork-ui/feature/search' import { getOptionalSearchConfig } from '@geonetwork-ui/util/app-config' +import { Observable, switchMap } from 'rxjs' +import { map } from 'rxjs/operators' @Component({ selector: 'datahub-search-filters', @@ -24,14 +27,28 @@ export class SearchFiltersComponent implements OnInit { filters: QueryList searchConfig: { fieldName: string; title: string }[] isOpen = false + userId: string + myRecordsFilterEnabled$: Observable = + this.searchFacade.searchFilters$.pipe( + switchMap((filters) => { + return this.fieldsService.readFieldValuesFromFilters(filters) + }), + map((fieldValues) => + fieldValues['owner'] && Array.isArray(fieldValues['owner']) + ? fieldValues['owner'].length > 0 + : !!fieldValues['owner'] + ) + ) constructor( public searchFacade: SearchFacade, private searchService: SearchService, - private fieldsService: FieldsService + private fieldsService: FieldsService, + private authService: AuthService ) {} ngOnInit(): void { + this.authService.user$.subscribe((user) => (this.userId = user?.id)) this.searchConfig = ( getOptionalSearchConfig().ADVANCED_FILTERS || [ 'publisher', @@ -70,6 +87,12 @@ export class SearchFiltersComponent implements OnInit { this.searchFacade.setSpatialFilterEnabled(enabled) } + toggleMyRecordsFilter(enabled: boolean) { + this.fieldsService + .buildFiltersFromFieldValues({ owner: enabled ? this.userId : [] }) + .subscribe((filters) => this.searchService.updateFilters(filters)) + } + clearFilters() { const fieldNames = this.filters.map((component) => component.fieldName) const fieldValues = fieldNames.reduce( diff --git a/apps/metadata-editor-e2e/src/e2e/my-org.cy.ts b/apps/metadata-editor-e2e/src/e2e/my-org.cy.ts new file mode 100644 index 0000000000..2362dbcc74 --- /dev/null +++ b/apps/metadata-editor-e2e/src/e2e/my-org.cy.ts @@ -0,0 +1,44 @@ +describe('my-org', () => { + beforeEach(() => { + cy.loginGN('barbie', 'p4ssworD_', false) + cy.visit(`/records/my-org`) + cy.get('md-editor-dashboard-menu').find('a').first().click() + cy.get('main').children('div').first().children('div').eq(1).as('linkGroup') + }) + describe('my-org display', () => { + it('should show my-org name and logo', () => { + cy.get('h1').should('not.have.text', '') + cy.get('gn-ui-thumbnail') + }) + it('should show the user and record count', () => { + cy.get('@linkGroup') + .find('a') + .children('span') + .first() + .should('not.have.text', '') + cy.get('@linkGroup') + .find('gn-ui-button') + .children('span') + .first() + .should('not.have.text', '') + }) + it('should show my-org records', () => { + cy.get('.grid').should('have.length.above', 0) + }) + }) + describe('routing', () => { + it('should access the datahub with a filter', () => { + cy.get('@linkGroup').find('a').click() + cy.url().should('include', 'search/publisher=') + }) + it('should access the user list page and show my-org users', () => { + cy.visit(`/records/my-org`) + cy.get('md-editor-dashboard-menu').find('a').first().click() + cy.get('@linkGroup').find('gn-ui-button').click() + cy.url().should('include', '/users/my-org') + cy.get('.grid').should('have.length.above', 0) + cy.get('h1').should('not.have.text', '') + cy.get('gn-ui-thumbnail') + }) + }) +}) diff --git a/apps/metadata-editor/project.json b/apps/metadata-editor/project.json index e341d7c513..e93f92e72b 100644 --- a/apps/metadata-editor/project.json +++ b/apps/metadata-editor/project.json @@ -103,6 +103,16 @@ "jestConfig": "apps/metadata-editor/jest.config.ts", "passWithNoTests": true } + }, + "docker-build": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "nx build metadata-editor --base-href='/metadata-editor/'", + "docker build --build-arg APP_NAME=metadata-editor -f ./tools/docker/Dockerfile.apps . -t $(tools/print-docker-tag.sh metadata-editor)" + ], + "parallel": false + } } }, "tags": ["type:app"] diff --git a/apps/metadata-editor/src/app/app.routes.ts b/apps/metadata-editor/src/app/app.routes.ts index 8f95f45a5d..e6fa2b5e1a 100644 --- a/apps/metadata-editor/src/app/app.routes.ts +++ b/apps/metadata-editor/src/app/app.routes.ts @@ -10,6 +10,7 @@ import { MyRecordsComponent } from './records/my-records/my-records.component' import { MyDraftComponent } from './records/my-draft/my-draft.component' import { MyLibraryComponent } from './records/my-library/my-library.component' import { SearchRecordsComponent } from './records/search-records/search-records-list.component' +import { MyOrgUsersComponent } from './my-org-users/my-org-users.component' export const appRoutes: Route[] = [ { path: '', component: DashboardPageComponent, pathMatch: 'prefix' }, @@ -61,6 +62,10 @@ export const appRoutes: Route[] = [ }, ], }, + { + path: 'users/my-org', + component: MyOrgUsersComponent, + }, { path: 'sign-in', component: SignInPageComponent }, { path: 'create', component: CreatePageComponent }, { diff --git a/apps/metadata-editor/src/app/my-org-users/my-org-users.component.css b/apps/metadata-editor/src/app/my-org-users/my-org-users.component.css new file mode 100644 index 0000000000..8318f27f3e --- /dev/null +++ b/apps/metadata-editor/src/app/my-org-users/my-org-users.component.css @@ -0,0 +1,7 @@ +.record-table-col { + @apply px-5 py-3 items-center truncate; +} + +.record-table-header { + @apply record-table-col capitalize; +} diff --git a/apps/metadata-editor/src/app/my-org-users/my-org-users.component.html b/apps/metadata-editor/src/app/my-org-users/my-org-users.component.html new file mode 100644 index 0000000000..1ecec479eb --- /dev/null +++ b/apps/metadata-editor/src/app/my-org-users/my-org-users.component.html @@ -0,0 +1,6 @@ + + diff --git a/apps/metadata-editor/src/app/my-org-users/my-org-users.component.spec.ts b/apps/metadata-editor/src/app/my-org-users/my-org-users.component.spec.ts new file mode 100644 index 0000000000..897fa36d43 --- /dev/null +++ b/apps/metadata-editor/src/app/my-org-users/my-org-users.component.spec.ts @@ -0,0 +1,131 @@ +import { MyOrgUsersComponent } from './my-org-users.component' +import { BehaviorSubject, of } from 'rxjs' +import { MyOrgService } from '@geonetwork-ui/feature/catalog' +import { + ORGANISATIONS_FIXTURE, + USER_FIXTURE_ANON, + USERS_FIXTURE, +} from '@geonetwork-ui/common/fixtures' +import { AuthService } from '@geonetwork-ui/api/repository/gn4' +import { SearchFacade } from '@geonetwork-ui/feature/search' + +describe('MyOrgUsersComponent', () => { + let component: MyOrgUsersComponent + let searchFacade: SearchFacade + let myOrgService: MyOrgService + let authService: AuthService + + beforeEach(() => { + const user = USER_FIXTURE_ANON() + const allUsers = USERS_FIXTURE() + + const myOrgServiceMock = { + myOrgData$: of({ + orgName: 'wizard-org', + logoUrl: 'https://my-geonetwork.org/logo11.png', + recordCount: 10, + userCount: 3, + userList: [ + { + id: '161', + profile: 'Administrator', + username: 'ghost16', + name: 'Ghost', + surname: 'Old', + email: 'old.ghost@wiz.fr', + organisation: 'wizard-org', + profileIcon: + 'https://www.gravatar.com/avatar/dbdffd183622800bcf8587328daf43a6?d=mp', + }, + { + id: '3', + profile: 'Editor', + username: 'voldy63', + name: 'Lord', + surname: 'Voldemort', + email: 'lord.voldy@wiz.com', + organisation: 'wizard-org', + }, + { + id: '4', + profile: 'Editor', + username: 'al.dumble98', + name: 'Albus', + surname: 'Dumbledore', + email: 'albus.dumble@wiz.com', + organisation: 'wizard-org', + }, + ], + }), + } + + const authServiceMock = { + user$: new BehaviorSubject(user), + allUsers$: new BehaviorSubject(allUsers), + } + + const organisationsServiceMock = { + organisations$: of(ORGANISATIONS_FIXTURE), + } + + const searchFacadeMock = { + resetSearch: jest.fn(), + } + + myOrgService = myOrgServiceMock as any + authService = authServiceMock as any + searchFacade = searchFacadeMock as any + + component = new MyOrgUsersComponent(myOrgService, searchFacade) + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) + + describe('Get organization users info', () => { + it('should get the org name', () => { + expect(component.orgData.orgName).toEqual('wizard-org') + }) + + it('should get the org logo', () => { + expect(component.orgData.logoUrl).toEqual( + 'https://my-geonetwork.org/logo11.png' + ) + }) + + it('should get the list of users', () => { + expect(component.orgData.userList).toEqual([ + { + id: '161', + profile: 'Administrator', + username: 'ghost16', + name: 'Ghost', + surname: 'Old', + email: 'old.ghost@wiz.fr', + organisation: 'wizard-org', + profileIcon: + 'https://www.gravatar.com/avatar/dbdffd183622800bcf8587328daf43a6?d=mp', + }, + { + id: '3', + profile: 'Editor', + username: 'voldy63', + name: 'Lord', + surname: 'Voldemort', + email: 'lord.voldy@wiz.com', + organisation: 'wizard-org', + }, + { + id: '4', + profile: 'Editor', + username: 'al.dumble98', + name: 'Albus', + surname: 'Dumbledore', + email: 'albus.dumble@wiz.com', + organisation: 'wizard-org', + }, + ]) + }) + }) +}) diff --git a/apps/metadata-editor/src/app/my-org-users/my-org-users.component.ts b/apps/metadata-editor/src/app/my-org-users/my-org-users.component.ts new file mode 100644 index 0000000000..aae4f1a49f --- /dev/null +++ b/apps/metadata-editor/src/app/my-org-users/my-org-users.component.ts @@ -0,0 +1,48 @@ +import { Component, OnDestroy } from '@angular/core' +import { RecordsListComponent } from '../records/records-list.component' +import { UiInputsModule } from '@geonetwork-ui/ui/inputs' +import { TranslateModule } from '@ngx-translate/core' +import { CommonModule } from '@angular/common' +import { MyOrgService } from '@geonetwork-ui/feature/catalog' +import { SearchFacade } from '@geonetwork-ui/feature/search' +import { UserApiModel } from '@geonetwork-ui/data-access/gn4' + +@Component({ + selector: 'md-editor-my-org-users', + templateUrl: './my-org-users.component.html', + styleUrls: ['./my-org-users.component.css'], + standalone: true, + imports: [ + RecordsListComponent, + UiInputsModule, + TranslateModule, + CommonModule, + ], +}) +export class MyOrgUsersComponent implements OnDestroy { + orgData: { + orgName: string + logoUrl: string + recordCount: number + userCount: number + userList: UserApiModel[] + } + + private myOrgDataSubscription + + constructor( + private myOrgRecordsService: MyOrgService, + public searchFacade: SearchFacade + ) { + this.searchFacade.resetSearch() + this.myOrgDataSubscription = this.myOrgRecordsService.myOrgData$.subscribe( + (data) => { + this.orgData = data + } + ) + } + + ngOnDestroy() { + this.myOrgDataSubscription.unsubscribe() + } +} diff --git a/apps/metadata-editor/src/app/records/my-org-records/my-org-records.component.html b/apps/metadata-editor/src/app/records/my-org-records/my-org-records.component.html index 200b0ba65e..f5365b2f10 100644 --- a/apps/metadata-editor/src/app/records/my-org-records/my-org-records.component.html +++ b/apps/metadata-editor/src/app/records/my-org-records/my-org-records.component.html @@ -1,2 +1,8 @@ - + diff --git a/apps/metadata-editor/src/app/records/my-org-records/my-org-records.component.spec.ts b/apps/metadata-editor/src/app/records/my-org-records/my-org-records.component.spec.ts index 7ca9a17948..63e0629753 100644 --- a/apps/metadata-editor/src/app/records/my-org-records/my-org-records.component.spec.ts +++ b/apps/metadata-editor/src/app/records/my-org-records/my-org-records.component.spec.ts @@ -1,108 +1,164 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing' import { MyOrgRecordsComponent } from './my-org-records.component' -import { SearchFacade, SearchService } from '@geonetwork-ui/feature/search' -import { Component, importProvidersFrom } from '@angular/core' -import { TranslateModule } from '@ngx-translate/core' -import { RecordsListComponent } from '../records-list.component' +import { BehaviorSubject, of } from 'rxjs' +import { MyOrgService } from '@geonetwork-ui/feature/catalog' import { - FILTERS_AGGREGATION, - USER_FIXTURE, + ORGANISATIONS_FIXTURE, + USER_FIXTURE_ANON, + USERS_FIXTURE, } from '@geonetwork-ui/common/fixtures' -import { BehaviorSubject, of } from 'rxjs' -import { OrganizationsServiceInterface } from '@geonetwork-ui/common/domain/organizations.service.interface' import { AuthService } from '@geonetwork-ui/api/repository/gn4' +import { SearchFacade } from '@geonetwork-ui/feature/search' +import { OrganizationsServiceInterface } from '@geonetwork-ui/common/domain/organizations.service.interface' +import { EditorRouterService } from '../../router.service' -const user = USER_FIXTURE() -const filters = FILTERS_AGGREGATION +const orgDataMock = { + orgName: 'wizard-org', + logoUrl: 'https://my-geonetwork.org/logo11.png', + recordCount: 10, + userCount: 3, + userList: [ + { + id: '161', + profile: 'Administrator', + username: 'ghost16', + name: 'Ghost', + surname: 'Old', + email: 'old.ghost@wiz.fr', + organisation: 'wizard-org', + profileIcon: + 'https://www.gravatar.com/avatar/dbdffd183622800bcf8587328daf43a6?d=mp', + }, + { + id: '3', + profile: 'Editor', + username: 'voldy63', + name: 'Lord', + surname: 'Voldemort', + email: 'lord.voldy@wiz.com', + organisation: 'wizard-org', + }, + { + id: '4', + profile: 'Editor', + username: 'al.dumble98', + name: 'Albus', + surname: 'Dumbledore', + email: 'albus.dumble@wiz.com', + organisation: 'wizard-org', + }, + ], +} -class AuthServiceMock { - user$ = new BehaviorSubject(user) - authReady = jest.fn(() => this._authSubject$) - _authSubject$ = new BehaviorSubject({}) +const myOrgServiceMock = { + myOrgData$: of(orgDataMock), } -class OrganisationsServiceMock { - getFiltersForOrgs = jest.fn(() => new BehaviorSubject(filters)) - organisationsCount$ = of(456) + +const user = USER_FIXTURE_ANON() +const allUsers = USERS_FIXTURE() + +const authServiceMock = { + user$: new BehaviorSubject(user), + allUsers$: new BehaviorSubject(allUsers), } -class searchServiceMock { - updateSearchFilters = jest.fn() - setSearch = jest.fn() - setSortBy = jest.fn() - setSortAndFilters = jest.fn() +const organisationsServiceMock = { + organisations$: of(ORGANISATIONS_FIXTURE), } -class SearchFacadeMock { - resetSearch = jest.fn() - setFilters = jest.fn() +const searchFacadeMock = { + resetSearch: jest.fn(), } -@Component({ - // eslint-disable-next-line - selector: 'md-editor-records-list', - template: '', - standalone: true, -}) -export class MockRecordsListComponent {} +const routeServiceMock = { + getDatahubSearchRoute: jest.fn(), +} describe('MyOrgRecordsComponent', () => { let component: MyOrgRecordsComponent - let fixture: ComponentFixture let searchFacade: SearchFacade - let orgService: OrganizationsServiceInterface + let myOrgService: MyOrgService + let authService: AuthService + let orgServiceInterface: OrganizationsServiceInterface + let routerService: EditorRouterService beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - importProvidersFrom(TranslateModule.forRoot()), - { - provide: SearchFacade, - useClass: SearchFacadeMock, - }, - { provide: AuthService, useClass: AuthServiceMock }, - { - provide: OrganizationsServiceInterface, - useClass: OrganisationsServiceMock, - }, - { - provide: SearchFacade, - useClass: SearchFacadeMock, - }, - { - provide: SearchService, - useClass: searchServiceMock, - }, - ], - }).overrideComponent(MyOrgRecordsComponent, { - remove: { - imports: [RecordsListComponent], - }, - add: { - imports: [MockRecordsListComponent], - }, - }) - searchFacade = TestBed.inject(SearchFacade) - orgService = TestBed.inject(OrganizationsServiceInterface) - fixture = TestBed.createComponent(MyOrgRecordsComponent) - component = fixture.componentInstance - fixture.detectChanges() + orgServiceInterface = organisationsServiceMock as any + myOrgService = myOrgServiceMock as any + authService = authServiceMock as any + searchFacade = searchFacadeMock as any + routerService = routeServiceMock as any + + component = new MyOrgRecordsComponent( + myOrgService, + searchFacade, + orgServiceInterface, + routerService + ) }) it('should create', () => { expect(component).toBeTruthy() }) - describe('filters', () => { - it('clears filters on init', () => { - expect(searchFacade.resetSearch).toHaveBeenCalled() + describe('Get organization users info', () => { + it('should get the org name', () => { + expect(component.orgData.orgName).toEqual('wizard-org') }) - it('filters by user organisation on init', () => { - expect(orgService.getFiltersForOrgs).toHaveBeenCalledWith([ + + it('should get the org logo', () => { + expect(component.orgData.logoUrl).toEqual( + 'https://my-geonetwork.org/logo11.png' + ) + }) + + it('should get the list of users', () => { + expect(component.orgData.userList).toEqual([ { - name: user.organisation, + id: '161', + profile: 'Administrator', + username: 'ghost16', + name: 'Ghost', + surname: 'Old', + email: 'old.ghost@wiz.fr', + organisation: 'wizard-org', + profileIcon: + 'https://www.gravatar.com/avatar/dbdffd183622800bcf8587328daf43a6?d=mp', + }, + { + id: '3', + profile: 'Editor', + username: 'voldy63', + name: 'Lord', + surname: 'Voldemort', + email: 'lord.voldy@wiz.com', + organisation: 'wizard-org', + }, + { + id: '4', + profile: 'Editor', + username: 'al.dumble98', + name: 'Albus', + surname: 'Dumbledore', + email: 'albus.dumble@wiz.com', + organisation: 'wizard-org', }, ]) - expect(searchFacade.setFilters).toHaveBeenCalledWith(filters) }) }) + it('should generate the correct Datahub URL', () => { + // Mock the router method and set orgData + component.router.getDatahubSearchRoute = () => 'http://example.com' + component.orgData = { + orgName: 'TestOrg', + logoUrl: '', + recordCount: 5, + userCount: 3, + userList: [], + } + + const datahubUrl = component.getDatahubUrl() + + // Assert that the generated URL contains the orgName + expect(datahubUrl).toContain('publisher=TestOrg') + }) }) diff --git a/apps/metadata-editor/src/app/records/my-org-records/my-org-records.component.ts b/apps/metadata-editor/src/app/records/my-org-records/my-org-records.component.ts index 7a2141398e..16fbbfebd0 100644 --- a/apps/metadata-editor/src/app/records/my-org-records/my-org-records.component.ts +++ b/apps/metadata-editor/src/app/records/my-org-records/my-org-records.component.ts @@ -2,11 +2,12 @@ import { Component, OnDestroy } from '@angular/core' import { CommonModule } from '@angular/common' import { TranslateModule } from '@ngx-translate/core' import { RecordsListComponent } from '../records-list.component' +import { MyOrgService } from '@geonetwork-ui/feature/catalog' import { SearchFacade } from '@geonetwork-ui/feature/search' -import { OrganizationsServiceInterface } from '@geonetwork-ui/common/domain/organizations.service.interface' import { Organization } from '@geonetwork-ui/common/domain/record' -import { Subscription } from 'rxjs' -import { AuthService } from '@geonetwork-ui/api/repository/gn4' +import { OrganizationsServiceInterface } from '@geonetwork-ui/common/domain/organizations.service.interface' +import { UserApiModel } from '@geonetwork-ui/data-access/gn4' +import { EditorRouterService } from '../../router.service' @Component({ selector: 'md-editor-my-org-records', @@ -16,29 +17,48 @@ import { AuthService } from '@geonetwork-ui/api/repository/gn4' imports: [CommonModule, TranslateModule, RecordsListComponent], }) export class MyOrgRecordsComponent implements OnDestroy { - subscriptionAuthService: Subscription - subscriptionOrgService: Subscription + orgData: { + orgName: string + logoUrl: string + recordCount: number + userCount: number + userList: UserApiModel[] + } + + public myOrgDataSubscription constructor( + private myOrgRecordsService: MyOrgService, public searchFacade: SearchFacade, - private authService: AuthService, - private orgService: OrganizationsServiceInterface + public orgService: OrganizationsServiceInterface, + public router: EditorRouterService ) { this.searchFacade.resetSearch() - - this.subscriptionAuthService = this.authService.user$.subscribe((user) => { - this.searchByOrganisation({ name: user?.organisation }) - }) + this.myOrgDataSubscription = this.myOrgRecordsService.myOrgData$.subscribe( + (data) => { + this.orgData = data + this.searchByOrganisation({ name: data.orgName }) + } + ) } searchByOrganisation(organisation: Organization) { - this.subscriptionOrgService = this.orgService + this.orgService .getFiltersForOrgs([organisation]) .subscribe((filters) => this.searchFacade.setFilters(filters)) } - ngOnDestroy(): void { - this.subscriptionAuthService.unsubscribe() - this.subscriptionOrgService.unsubscribe() + getDatahubUrl(): string { + const url = new URL( + this.router.getDatahubSearchRoute(), + window.location.toString() + ) + + url.searchParams.append('publisher', this.orgData?.orgName) + return url.toString() + } + + ngOnDestroy() { + this.myOrgDataSubscription.unsubscribe() } } diff --git a/apps/metadata-editor/src/app/records/my-records/my-records.component.html b/apps/metadata-editor/src/app/records/my-records/my-records.component.html index 8b3aa4d840..049f7af0c3 100644 --- a/apps/metadata-editor/src/app/records/my-records/my-records.component.html +++ b/apps/metadata-editor/src/app/records/my-records/my-records.component.html @@ -1,2 +1,5 @@ - + diff --git a/apps/metadata-editor/src/app/records/my-records/my-records.component.spec.ts b/apps/metadata-editor/src/app/records/my-records/my-records.component.spec.ts index f1bca7b7ba..f1fbc3d519 100644 --- a/apps/metadata-editor/src/app/records/my-records/my-records.component.spec.ts +++ b/apps/metadata-editor/src/app/records/my-records/my-records.component.spec.ts @@ -1,9 +1,13 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { MyRecordsComponent } from './my-records.component' -import { SearchFacade } from '@geonetwork-ui/feature/search' -import { Component, importProvidersFrom } from '@angular/core' +import { FieldsService, SearchFacade } from '@geonetwork-ui/feature/search' +import { Component, importProvidersFrom, Input } from '@angular/core' import { TranslateModule } from '@ngx-translate/core' import { RecordsListComponent } from '../records-list.component' +import { BehaviorSubject, of } from 'rxjs' +import { USER_FIXTURE } from '@geonetwork-ui/common/fixtures' +import { AuthService } from '@geonetwork-ui/api/repository/gn4' +import { EditorRouterService } from '../../router.service' @Component({ // eslint-disable-next-line @@ -11,10 +15,27 @@ import { RecordsListComponent } from '../records-list.component' template: '', standalone: true, }) -export class MockRecordsListComponent {} +export class MockRecordsListComponent { + @Input() linkToDatahub: string +} +const user = USER_FIXTURE() class SearchFacadeMock { resetSearch = jest.fn() + updateFilters = jest.fn() +} +class EditorRouterServiceMock { + getDatahubSearchRoute = jest.fn(() => `/datahub/`) +} + +class AuthServiceMock { + user$ = new BehaviorSubject(user) + authReady = jest.fn(() => this._authSubject$) + _authSubject$ = new BehaviorSubject({}) +} + +class FieldsServiceMock { + buildFiltersFromFieldValues = jest.fn((val) => of(val)) } describe('MyRecordsComponent', () => { @@ -26,10 +47,22 @@ describe('MyRecordsComponent', () => { TestBed.configureTestingModule({ providers: [ importProvidersFrom(TranslateModule.forRoot()), + { + provide: FieldsService, + useClass: FieldsServiceMock, + }, { provide: SearchFacade, useClass: SearchFacadeMock, }, + { + provide: AuthService, + useClass: AuthServiceMock, + }, + { + provide: EditorRouterService, + useClass: EditorRouterServiceMock, + }, ], }).overrideComponent(MyRecordsComponent, { remove: { @@ -53,5 +86,18 @@ describe('MyRecordsComponent', () => { it('clears filters on init', () => { expect(searchFacade.resetSearch).toHaveBeenCalled() }) + it('Update filters on init', () => { + expect(searchFacade.updateFilters).toHaveBeenCalledWith({ + owner: user.id, + }) + }) + }) + + describe('datahub url', () => { + it('get correct url', () => { + expect(component.getDatahubUrl()).toEqual( + 'http://localhost/datahub/?owner=46798' + ) + }) }) }) diff --git a/apps/metadata-editor/src/app/records/my-records/my-records.component.ts b/apps/metadata-editor/src/app/records/my-records/my-records.component.ts index c6c1e0bce7..cd12f08f91 100644 --- a/apps/metadata-editor/src/app/records/my-records/my-records.component.ts +++ b/apps/metadata-editor/src/app/records/my-records/my-records.component.ts @@ -1,8 +1,11 @@ -import { Component } from '@angular/core' +import { Component, OnDestroy, OnInit } from '@angular/core' import { CommonModule } from '@angular/common' import { TranslateModule } from '@ngx-translate/core' import { RecordsListComponent } from '../records-list.component' -import { SearchFacade } from '@geonetwork-ui/feature/search' +import { FieldsService, SearchFacade } from '@geonetwork-ui/feature/search' +import { AuthService } from '@geonetwork-ui/api/repository/gn4' +import { EditorRouterService } from '../../router.service' +import { Subscription } from 'rxjs' @Component({ selector: 'md-editor-my-records', @@ -11,8 +14,41 @@ import { SearchFacade } from '@geonetwork-ui/feature/search' standalone: true, imports: [CommonModule, TranslateModule, RecordsListComponent], }) -export class MyRecordsComponent { - constructor(public searchFacade: SearchFacade) { +export class MyRecordsComponent implements OnInit, OnDestroy { + private sub: Subscription + private ownerId: string + + constructor( + public fieldsService: FieldsService, + public searchFacade: SearchFacade, + private authService: AuthService, + private router: EditorRouterService + ) {} + + ngOnInit() { this.searchFacade.resetSearch() + this.sub = this.authService.user$.subscribe((user) => { + this.ownerId = user.id + this.fieldsService + .buildFiltersFromFieldValues({ owner: user.id }) + .subscribe((filters) => { + this.searchFacade.updateFilters(filters) + }) + }) + } + + getDatahubUrl(): string { + const url = new URL( + `${this.router.getDatahubSearchRoute()}`, + this.router.getDatahubSearchRoute().startsWith('http') + ? this.router.getDatahubSearchRoute() + : window.location.toString() + ) + url.searchParams.append('owner', this.ownerId) + return url.toString() + } + + ngOnDestroy(): void { + this.sub.unsubscribe() } } diff --git a/apps/metadata-editor/src/app/records/records-list.component.html b/apps/metadata-editor/src/app/records/records-list.component.html index 01198dccb5..ef2f49181f 100644 --- a/apps/metadata-editor/src/app/records/records-list.component.html +++ b/apps/metadata-editor/src/app/records/records-list.component.html @@ -1,14 +1,68 @@
-
-

{{ title }}

+
+
+
+ + +
+
+

{{ title }}

+ np +

+ dashboard.records.users +

+
+
+ + {{ searchFacade.resultsHits$ | async }} + {{ 'record.metadata.publications' | translate }} + +
+ {{ recordCount }} + dashboard.records.publishedRecords + {{ userCount }} + dashboard.records.users +
+
+ dashboard.records.noUser + dashboard.records.noRecord +
{ useClass: AvatarServiceInterfaceMock, }, ], + imports: [HttpClientTestingModule], }) }) diff --git a/libs/api/repository/src/lib/gn4/auth/auth.service.ts b/libs/api/repository/src/lib/gn4/auth/auth.service.ts index 820c89107b..b901e4fdbb 100644 --- a/libs/api/repository/src/lib/gn4/auth/auth.service.ts +++ b/libs/api/repository/src/lib/gn4/auth/auth.service.ts @@ -2,6 +2,8 @@ import { Inject, Injectable, InjectionToken, Optional } from '@angular/core' import { MeApiService, MeResponseApiModel, + UserApiModel, + UsersApiService, } from '@geonetwork-ui/data-access/gn4' import { LANG_2_TO_3_MAPPER } from '@geonetwork-ui/util/i18n' import { UserModel } from '@geonetwork-ui/common/domain/user.model' @@ -19,6 +21,7 @@ export const LOGIN_URL = new InjectionToken('loginUrl') export class AuthService { authReady$: Observable user$: Observable + allUsers$: Observable isAnonymous$ = this.authReady().pipe(map((user) => !user || !('id' in user))) baseLoginUrl = this.baseLoginUrlToken || DEFAULT_GN4_LOGIN_URL @@ -42,6 +45,7 @@ export class AuthService { @Inject(LOGIN_URL) private baseLoginUrlToken: string, private meApi: MeApiService, + private usersApi: UsersApiService, private translateService: TranslateService, private avatarService: AvatarServiceInterface ) { @@ -49,6 +53,7 @@ export class AuthService { map((apiUser) => this.mapToUserModel(apiUser)), shareReplay({ bufferSize: 1, refCount: true }) ) + this.allUsers$ = this.usersApi.getUsers().pipe(shareReplay()) } // TODO: refactor authReady diff --git a/libs/api/repository/src/lib/gn4/elasticsearch/elasticsearch.service.spec.ts b/libs/api/repository/src/lib/gn4/elasticsearch/elasticsearch.service.spec.ts index 4dbe744ef7..1851409542 100644 --- a/libs/api/repository/src/lib/gn4/elasticsearch/elasticsearch.service.spec.ts +++ b/libs/api/repository/src/lib/gn4/elasticsearch/elasticsearch.service.spec.ts @@ -1,6 +1,6 @@ import { ElasticsearchService } from './elasticsearch.service' import { ES_FIXTURE_AGGS_RESPONSE } from '@geonetwork-ui/common/fixtures' -import { EsSearchParams } from '../types/elasticsearch.model' +import { EsSearchParams } from '@geonetwork-ui/api/metadata-converter' describe('ElasticsearchService', () => { let service: ElasticsearchService @@ -96,26 +96,31 @@ describe('ElasticsearchService', () => { }) }) describe('#buildPayloadQuery', () => { - it('add any and other fields query_strings', () => { + it('should not add fields query_strings if fieldsSearchFilters Object is empty', () => { const query = service['buildPayloadQuery']( { - Org: { - world: true, - }, any: 'hello', }, - {} + {}, + ['record-1', 'record-2', 'record-3'] ) + expect(query).toEqual({ bool: { - filter: [], - should: [], - must: [ + filter: [ { terms: { isTemplate: ['n'], }, }, + { + ids: { + values: ['record-1', 'record-2', 'record-3'], + }, + }, + ], + should: [], + must: [ { query_string: { default_operator: 'AND', @@ -130,11 +135,6 @@ describe('ElasticsearchService', () => { query: 'hello', }, }, - { - query_string: { - query: '(Org:"world")', - }, - }, ], must_not: { terms: { @@ -157,14 +157,25 @@ describe('ElasticsearchService', () => { ) expect(query).toEqual({ bool: { - filter: [], - should: [], - must: [ + filter: [ { terms: { isTemplate: ['n'], }, }, + { + query_string: { + query: 'Org:("world")', + }, + }, + { + ids: { + values: ['record-1', 'record-2', 'record-3'], + }, + }, + ], + should: [], + must: [ { query_string: { default_operator: 'AND', @@ -179,16 +190,6 @@ describe('ElasticsearchService', () => { query: 'hello', }, }, - { - query_string: { - query: '(Org:"world")', - }, - }, - { - ids: { - values: ['record-1', 'record-2', 'record-3'], - }, - }, ], must_not: { terms: { @@ -203,6 +204,10 @@ describe('ElasticsearchService', () => { { Org: { world: true, + world2: true, + }, + name: { + john: true, }, any: 'hello', }, @@ -211,14 +216,25 @@ describe('ElasticsearchService', () => { ) expect(query).toEqual({ bool: { - filter: [], - should: [], - must: [ + filter: [ { terms: { isTemplate: ['n'], }, }, + { + query_string: { + query: 'Org:("world" OR "world2") AND name:("john")', + }, + }, + { + ids: { + values: [], + }, + }, + ], + should: [], + must: [ { query_string: { default_operator: 'AND', @@ -233,9 +249,38 @@ describe('ElasticsearchService', () => { query: 'hello', }, }, + ], + must_not: { + terms: { + resourceType: ['service', 'map', 'map/static', 'mapDigital'], + }, + }, + }, + }) + }) + it('handle negative and empty filters', () => { + const query = service['buildPayloadQuery']( + { + Org: { + world: false, + }, + name: {}, + message: '', + }, + {}, + [] + ) + expect(query).toEqual({ + bool: { + filter: [ + { + terms: { + isTemplate: ['n'], + }, + }, { query_string: { - query: '(Org:"world")', + query: 'Org:(-"world")', }, }, { @@ -244,6 +289,61 @@ describe('ElasticsearchService', () => { }, }, ], + should: [], + must: [], + must_not: { + terms: { + resourceType: ['service', 'map', 'map/static', 'mapDigital'], + }, + }, + }, + }) + }) + it('handle filters expressed as queries', () => { + const query = service['buildPayloadQuery']( + { + Org: 'world AND world2', + any: 'hello', + }, + {}, + [] + ) + expect(query).toEqual({ + bool: { + filter: [ + { + terms: { + isTemplate: ['n'], + }, + }, + { + query_string: { + query: 'Org:(world AND world2)', + }, + }, + { + ids: { + values: [], + }, + }, + ], + should: [], + must: [ + { + query_string: { + default_operator: 'AND', + fields: [ + 'resourceTitleObject.langfre^5', + 'tag.langfre^4', + 'resourceAbstractObject.langfre^3', + 'lineageObject.langfre^2', + 'any.langfre', + 'uuid', + ], + query: 'hello', + }, + }, + ], must_not: { terms: { resourceType: ['service', 'map', 'map/static', 'mapDigital'], @@ -264,7 +364,7 @@ describe('ElasticsearchService', () => { ) }) it('escapes special char', () => { - expect(query.bool.must[1].query_string.query).toEqual( + expect(query.bool.must[0].query_string.query).toEqual( `scot \\(\\)\\{\\?\\[ \\/ test` ) }) @@ -287,9 +387,7 @@ describe('ElasticsearchService', () => { it('adds boosting of 7 for intersecting with it and boosting of 10 on geoms within', () => { const query = service['buildPayloadQuery']( { - Org: { - world: true, - }, + Org: 'world', any: 'hello', }, {}, @@ -298,13 +396,19 @@ describe('ElasticsearchService', () => { ) expect(query).toEqual({ bool: { - filter: [], - must: [ + filter: [ { terms: { isTemplate: ['n'], }, }, + { + query_string: { + query: 'Org:(world)', + }, + }, + ], + must: [ { query_string: { default_operator: 'AND', @@ -319,11 +423,6 @@ describe('ElasticsearchService', () => { query: 'hello', }, }, - { - query_string: { - query: '(Org:"world")', - }, - }, ], must_not: { terms: { @@ -611,6 +710,7 @@ describe('ElasticsearchService', () => { filters: { filter1: { field1: '100' }, filter2: { field2: { value1: true, value3: true } }, + filter3: 'my own query', }, }, myHistogram: { @@ -623,14 +723,13 @@ describe('ElasticsearchService', () => { myFilters: { filters: { filter1: { - match: { - field1: '100', - }, + query_string: { query: 'field1:(100)' }, }, filter2: { - match: { - field2: { value1: true, value3: true }, - }, + query_string: { query: 'field2:("value1" OR "value3")' }, + }, + filter3: { + query_string: { query: 'my own query' }, }, }, }, diff --git a/libs/api/repository/src/lib/gn4/elasticsearch/elasticsearch.service.ts b/libs/api/repository/src/lib/gn4/elasticsearch/elasticsearch.service.ts index 8a9b92af1d..4da929819e 100644 --- a/libs/api/repository/src/lib/gn4/elasticsearch/elasticsearch.service.ts +++ b/libs/api/repository/src/lib/gn4/elasticsearch/elasticsearch.service.ts @@ -5,6 +5,8 @@ import { Aggregation, AggregationParams, AggregationsParams, + FieldFilter, + FieldFilters, FilterAggregationParams, SortByField, } from '@geonetwork-ui/common/domain/search' @@ -177,17 +179,36 @@ export class ElasticsearchService { }) } + private filtersToQueryString(filters: FieldFilters): string { + const makeQuery = (filter: FieldFilter): string => { + if (typeof filter === 'string') { + return filter + } + return Object.keys(filter) + .map((key) => { + if (filter[key] === true) { + return `"${key}"` + } + return `-"${key}"` + }) + .join(' OR ') + } + return Object.keys(filters) + .filter( + (fieldname) => + filters[fieldname] && JSON.stringify(filters[fieldname]) !== '{}' + ) + .map((fieldname) => `${fieldname}:(${makeQuery(filters[fieldname])})`) + .join(' AND ') + } + private buildPayloadQuery( { any, ...fieldSearchFilters }: SearchFilters, configFilters: SearchFilters, uuids?: string[], geometry?: Geometry ) { - const queryFilters = this.stateFiltersToQueryString(fieldSearchFilters) - const must = [this.queryFilterOnValues('isTemplate', 'n')] as Record< - string, - unknown - >[] + const must = [] as Record[] const must_not = { ...this.queryFilterOnValues('resourceType', [ 'service', @@ -197,6 +218,10 @@ export class ElasticsearchService { ]), } const should = [] as Record[] + const filter = [this.queryFilterOnValues('isTemplate', 'n')] as Record< + string, + unknown + >[] if (any) { must.push({ @@ -210,15 +235,16 @@ export class ElasticsearchService { }, }) } + const queryFilters = this.filtersToQueryString(fieldSearchFilters) if (queryFilters) { - must.push({ + filter.push({ query_string: { query: queryFilters, }, }) } if (uuids) { - must.push({ + filter.push({ ids: { values: uuids, }, @@ -252,7 +278,7 @@ export class ElasticsearchService { must, must_not, should, - filter: [], + filter, }, } } @@ -348,6 +374,7 @@ export class ElasticsearchService { * } * } */ + // FIXME: this is not used anymore stateFiltersToQueryString(facetsState) { const query = [] for (const indexKey in facetsState) { @@ -365,6 +392,7 @@ export class ElasticsearchService { return this.combineQueryGroups(query) } + // FIXME: this is not used anymore private parseStateNode(nodeName, node, indexKey) { let queryString = '' if (node && typeof node === 'object') { @@ -416,20 +444,24 @@ export class ElasticsearchService { } buildAggregationsPayload(aggregations: AggregationsParams): any { - const mapFilterAggregation = (filterAgg: FilterAggregationParams) => ({ - match: filterAgg, - }) const mapToESAggregation = (aggregation: AggregationParams) => { switch (aggregation.type) { case 'filters': return { - filters: Object.keys(aggregation.filters).reduce( - (prev, curr) => ({ + filters: Object.keys(aggregation.filters).reduce((prev, curr) => { + const filter = aggregation.filters[curr] + return { ...prev, - [curr]: mapFilterAggregation(aggregation.filters[curr]), - }), - {} - ), + [curr]: { + query_string: { + query: + typeof filter === 'string' + ? filter + : this.filtersToQueryString(filter), + }, + }, + } + }, {}), } case 'terms': return { diff --git a/libs/api/repository/src/lib/gn4/organizations/organizations-from-metadata.service.spec.ts b/libs/api/repository/src/lib/gn4/organizations/organizations-from-metadata.service.spec.ts index 09fd07fcb9..879763bec1 100644 --- a/libs/api/repository/src/lib/gn4/organizations/organizations-from-metadata.service.spec.ts +++ b/libs/api/repository/src/lib/gn4/organizations/organizations-from-metadata.service.spec.ts @@ -270,7 +270,7 @@ describe.each(['4.2.2-00', '4.2.3-xx', '4.2.5-xx'])( size: 0, query: { bool: { - must: [{ terms: { isTemplate: ['n'] } }], + must: [], must_not: { terms: { resourceType: [ @@ -282,7 +282,7 @@ describe.each(['4.2.2-00', '4.2.3-xx', '4.2.5-xx'])( }, }, should: [], - filter: [], + filter: [{ terms: { isTemplate: ['n'] } }], }, }, _source: [], diff --git a/libs/common/domain/src/lib/search/aggregation.model.ts b/libs/common/domain/src/lib/search/aggregation.model.ts index 443c9149b5..77a0b150fb 100644 --- a/libs/common/domain/src/lib/search/aggregation.model.ts +++ b/libs/common/domain/src/lib/search/aggregation.model.ts @@ -1,5 +1,5 @@ import { FieldName } from './search.model' -import { FieldFilter } from './filter.model' +import { FieldFilters } from './filter.model' export interface TermsAggregationParams { type: 'terms' @@ -13,7 +13,7 @@ export interface HistogramAggregationParams { field: FieldName interval: number } -export type FilterAggregationParams = Record | string +export type FilterAggregationParams = FieldFilters | string export interface FiltersAggregationParams { type: 'filters' filters: Record diff --git a/libs/common/fixtures/src/lib/organisations.fixture.ts b/libs/common/fixtures/src/lib/organisations.fixture.ts index f78bd303cb..833d53a5b7 100644 --- a/libs/common/fixtures/src/lib/organisations.fixture.ts +++ b/libs/common/fixtures/src/lib/organisations.fixture.ts @@ -68,4 +68,10 @@ export const ORGANISATIONS_FIXTURE: Organization[] = deepFreeze([ logoUrl: new URL('https://my-geonetwork.org/logo10.png'), recordCount: 2, }, + { + name: 'wizard-org', + description: 'another org for testing', + logoUrl: new URL('https://my-geonetwork.org/logo11.png'), + recordCount: 2, + }, ]) diff --git a/libs/common/fixtures/src/lib/user.fixtures.ts b/libs/common/fixtures/src/lib/user.fixtures.ts index a1e9cea331..654fc14d21 100644 --- a/libs/common/fixtures/src/lib/user.fixtures.ts +++ b/libs/common/fixtures/src/lib/user.fixtures.ts @@ -12,8 +12,21 @@ export const USER_FIXTURE = (): UserModel => ({ 'https://www.gravatar.com/avatar/dbdffd183622800bcf8587328daf43a6?d=mp', }) +export const USER_FIXTURE_ANON = (): UserModel => ({ + id: '161', + profile: 'Administrator', + username: 'ghost16', + name: 'Ghost', + surname: 'Old', + email: 'old.ghost@wiz.fr', + organisation: 'wizard-org', + profileIcon: + 'https://www.gravatar.com/avatar/dbdffd183622800bcf8587328daf43a6?d=mp', +}) + export const USERS_FIXTURE = (): UserModel[] => [ USER_FIXTURE(), + USER_FIXTURE_ANON(), { id: '1', profile: 'Editor', @@ -32,4 +45,22 @@ export const USERS_FIXTURE = (): UserModel[] => [ email: 't.trinity@matrix.com', organisation: 'The matrix', }, + { + id: '3', + profile: 'Editor', + username: 'voldy63', + name: 'Lord', + surname: 'Voldemort', + email: 'lord.voldy@wiz.com', + organisation: 'wizard-org', + }, + { + id: '4', + profile: 'Editor', + username: 'al.dumble98', + name: 'Albus', + surname: 'Dumblerdore', + email: 'albus.dumble@wiz.com', + organisation: 'wizard-org', + }, ] diff --git a/libs/feature/catalog/src/index.ts b/libs/feature/catalog/src/index.ts index 38ad1c9e7a..fee6dd1389 100644 --- a/libs/feature/catalog/src/index.ts +++ b/libs/feature/catalog/src/index.ts @@ -5,3 +5,4 @@ export * from './lib/feature-catalog.module' export * from './lib/sources/sources.service' export * from './lib/sources/sources.model' export * from './lib/records/records.service' +export * from './lib/my-org/my-org.service' diff --git a/libs/feature/catalog/src/lib/my-org/my-org.service.spec.ts b/libs/feature/catalog/src/lib/my-org/my-org.service.spec.ts new file mode 100644 index 0000000000..e4def91fa1 --- /dev/null +++ b/libs/feature/catalog/src/lib/my-org/my-org.service.spec.ts @@ -0,0 +1,113 @@ +import { TestBed } from '@angular/core/testing' +import { MyOrgService } from './my-org.service' +import { AuthService } from '@geonetwork-ui/api/repository/gn4' +import { OrganizationsServiceInterface } from '@geonetwork-ui/common/domain/organizations.service.interface' +import { BehaviorSubject, Observable, of, Subject } from 'rxjs' +import { UserApiModel } from '@geonetwork-ui/data-access/gn4' +import { UserModel } from '@geonetwork-ui/common/domain/user.model' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { TranslateService } from '@ngx-translate/core' +import { AvatarServiceInterface } from '@geonetwork-ui/api/repository/gn4' + +const translateServiceMock = { + currentLang: 'fr', +} + +class AvatarServiceInterfaceMock { + placeholder = 'http://placeholder.com' + getProfileIcon = (hash: string) => `${hash}` +} + +const orgs = [ + { name: 'Géo2France', logoUrl: { href: 'logo-url' }, recordCount: 10 }, +] +const orgs$ = of(orgs) + +class orgServiceMock { + organisations$ = orgs$ +} + +describe('MyOrgService', () => { + let myOrgService: MyOrgService + let authService: AuthService + let orgService: OrganizationsServiceInterface + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + MyOrgService, + { + provide: TranslateService, + useValue: translateServiceMock, + }, + { + provide: AvatarServiceInterface, + useClass: AvatarServiceInterfaceMock, + }, + { provide: OrganizationsServiceInterface, useClass: orgServiceMock }, + AuthService, + ], + imports: [HttpClientTestingModule], + }) + myOrgService = TestBed.inject(MyOrgService) + authService = TestBed.inject(AuthService) + orgService = TestBed.inject(OrganizationsServiceInterface) + }) + + it('should be created', () => { + expect(myOrgService).toBeTruthy() + }) + + it('should update myOrgDataSubject when authService user$ emits a user', () => { + const userSubject = new BehaviorSubject(null) + const user: UserModel = { + organisation: 'Géo2France', + id: '2', + profile: 'profile', + username: 'username', + name: 'name', + surname: 'surname', + email: 'email@email', + profileIcon: 'icon.com', + } + authService.user$ = userSubject.asObservable() + + userSubject.next(user) + + myOrgService.myOrgData$.subscribe((data) => { + expect(data.orgName).toEqual('Géo2France') + }) + }) + + it('should update myOrgDataSubject when orgService organisations$ emits organizations', () => { + const orgsSubject = new BehaviorSubject([]) + const orgs = [ + { name: 'Géo2France', logoUrl: { href: 'logo-url' }, recordCount: 10 }, + ] + orgService.organisations$ = orgsSubject.asObservable() + + orgsSubject.next(orgs) + + myOrgService.myOrgData$.subscribe((data) => { + expect(data.orgName).toEqual('Géo2France') + expect(data.logoUrl).toEqual('logo-url') + expect(data.recordCount).toEqual(10) + }) + }) + + it('should update myOrgDataSubject when authService allUsers$ emits users', () => { + const allUsersSubject = new BehaviorSubject([]) + const users: UserApiModel[] = [ + { organisation: 'Géo2France' }, + { organisation: 'Géo2France' }, + ] + authService.allUsers$ = allUsersSubject.asObservable() + + allUsersSubject.next(users) + + myOrgService.myOrgData$.subscribe((data) => { + expect(data.orgName).toEqual('Géo2France') + expect(data.userList.length).toEqual(2) + }) + }) +}) diff --git a/libs/feature/catalog/src/lib/my-org/my-org.service.ts b/libs/feature/catalog/src/lib/my-org/my-org.service.ts new file mode 100644 index 0000000000..b2250696fb --- /dev/null +++ b/libs/feature/catalog/src/lib/my-org/my-org.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@angular/core' +import { OrganizationsServiceInterface } from '@geonetwork-ui/common/domain/organizations.service.interface' +import { AuthService } from '@geonetwork-ui/api/repository/gn4' +import { BehaviorSubject, combineLatest, map, Observable } from 'rxjs' +import { UserApiModel } from '@geonetwork-ui/data-access/gn4' + +@Injectable({ + providedIn: 'root', +}) +export class MyOrgService { + myOrgData$: Observable<{ + orgName: string + logoUrl: string + recordCount: number + userCount: number + userList: UserApiModel[] + }> + + private myOrgDataSubject = new BehaviorSubject<{ + orgName: string + logoUrl: string + recordCount: number + userCount: number + userList: UserApiModel[] + }>({ + orgName: '', + logoUrl: '', + recordCount: 0, + userCount: 0, + userList: [], + }) + + constructor( + private authService: AuthService, + private orgService: OrganizationsServiceInterface + ) { + this.myOrgData$ = combineLatest([ + this.authService.user$, + this.authService.allUsers$, + this.orgService.organisations$, + ]).pipe( + map(([user, allUsers, orgs]) => { + const orgName = user.organisation + const org = orgs.find((org) => org.name === orgName) + const logoUrl = org?.logoUrl?.href.toString() + const recordCount = org?.recordCount + const userList = allUsers.filter( + (user) => user.organisation === orgName + ) + const userCount = userList.length + return { + orgName, + logoUrl, + recordCount, + userList, + userCount, + } + }) + ) + } +} diff --git a/libs/feature/catalog/src/lib/organisations/organisations.component.spec.ts b/libs/feature/catalog/src/lib/organisations/organisations.component.spec.ts index 7ff3a3fca1..d5ef56539e 100644 --- a/libs/feature/catalog/src/lib/organisations/organisations.component.spec.ts +++ b/libs/feature/catalog/src/lib/organisations/organisations.component.spec.ts @@ -148,7 +148,7 @@ describe('OrganisationsComponent', () => { expect( orgPreviewComponents[orgPreviewComponents.length - 1].organisation .name - ).toEqual('J Data Org') + ).toEqual('wizard-org') }) }) }) diff --git a/libs/feature/search/src/lib/state/search.facade.ts b/libs/feature/search/src/lib/state/search.facade.ts index 4154dd780a..9300c61985 100644 --- a/libs/feature/search/src/lib/state/search.facade.ts +++ b/libs/feature/search/src/lib/state/search.facade.ts @@ -27,6 +27,7 @@ import { currentPage, getError, getFavoritesOnly, + getPageSize, getSearchConfigAggregations, getSearchFilters, getSearchResults, @@ -35,7 +36,6 @@ import { getSearchResultsLayout, getSearchResultsLoading, getSearchSortBy, - getPageSize, getSpatialFilterEnabled, isEndOfResults, totalPages, diff --git a/libs/feature/search/src/lib/utils/service/fields.service.spec.ts b/libs/feature/search/src/lib/utils/service/fields.service.spec.ts index 4f82adf377..d2b8d9b669 100644 --- a/libs/feature/search/src/lib/utils/service/fields.service.spec.ts +++ b/libs/feature/search/src/lib/utils/service/fields.service.spec.ts @@ -77,6 +77,7 @@ describe('FieldsService', () => { 'isSpatial', 'q', 'license', + 'owner', ]) }) }) @@ -156,6 +157,7 @@ describe('FieldsService', () => { representationType: [], resourceType: [], topic: [], + owner: [], }) }) }) diff --git a/libs/feature/search/src/lib/utils/service/fields.service.ts b/libs/feature/search/src/lib/utils/service/fields.service.ts index b1b69d15b5..8c2bed40ae 100644 --- a/libs/feature/search/src/lib/utils/service/fields.service.ts +++ b/libs/feature/search/src/lib/utils/service/fields.service.ts @@ -7,6 +7,7 @@ import { IsSpatialSearchField, LicenseSearchField, OrganizationSearchField, + OwnerSearchField, SimpleSearchField, } from './fields' import { forkJoin, Observable, of } from 'rxjs' @@ -64,6 +65,7 @@ export class FieldsService { isSpatial: new IsSpatialSearchField(this.injector), q: new FullTextSearchField(), license: new LicenseSearchField(this.injector), + owner: new OwnerSearchField(this.injector), } as Record get supportedFields() { diff --git a/libs/feature/search/src/lib/utils/service/fields.ts b/libs/feature/search/src/lib/utils/service/fields.ts index 415c5fcc53..272fb73362 100644 --- a/libs/feature/search/src/lib/utils/service/fields.ts +++ b/libs/feature/search/src/lib/utils/service/fields.ts @@ -313,3 +313,12 @@ export class OrganizationSearchField implements AbstractSearchField { ) } } +export class OwnerSearchField extends SimpleSearchField { + constructor(injector: Injector) { + super('owner', 'asc', injector) + } + + getAvailableValues(): Observable { + return of([]) + } +} diff --git a/libs/feature/search/src/lib/utils/service/search.service.ts b/libs/feature/search/src/lib/utils/service/search.service.ts index 92576e73ba..f168cb8894 100644 --- a/libs/feature/search/src/lib/utils/service/search.service.ts +++ b/libs/feature/search/src/lib/utils/service/search.service.ts @@ -40,4 +40,8 @@ export class SearchService implements SearchServiceI { setPage(page: number): void { this.facade.paginate(page) } + + resetSearch(): void { + this.facade.resetSearch() + } } diff --git a/libs/ui/search/src/lib/record-table/record-table.component.html b/libs/ui/search/src/lib/record-table/record-table.component.html index 61c0242027..c4a818b997 100644 --- a/libs/ui/search/src/lib/record-table/record-table.component.html +++ b/libs/ui/search/src/lib/record-table/record-table.component.html @@ -147,7 +147,10 @@ > {{ formats[1] }} -
+
+{{ formats.slice(2).length }}
diff --git a/libs/util/app-config/src/lib/app-config.ts b/libs/util/app-config/src/lib/app-config.ts index f84f694b80..d5a7b49a3c 100644 --- a/libs/util/app-config/src/lib/app-config.ts +++ b/libs/util/app-config/src/lib/app-config.ts @@ -83,6 +83,7 @@ export function loadAppConfig() { 'global', ['geonetwork4_api_url'], [ + 'datahub_url', 'proxy_path', 'metadata_language', 'login_url', @@ -103,6 +104,7 @@ export function loadAppConfig() { ? null : ({ GN4_API_URL: parsedGlobalSection.geonetwork4_api_url, + DATAHUB_URL: parsedGlobalSection.datahub_url, PROXY_PATH: parsedGlobalSection.proxy_path, METADATA_LANGUAGE: parsedGlobalSection.metadata_language ? ( diff --git a/libs/util/app-config/src/lib/model.ts b/libs/util/app-config/src/lib/model.ts index a259d16b99..5189e3984e 100644 --- a/libs/util/app-config/src/lib/model.ts +++ b/libs/util/app-config/src/lib/model.ts @@ -2,6 +2,7 @@ import { Geometry } from 'geojson' export interface GlobalConfig { GN4_API_URL: string + DATAHUB_URL?: string PROXY_PATH?: string METADATA_LANGUAGE?: string LOGIN_URL?: string diff --git a/translations/de.json b/translations/de.json index d4da8d0e2c..dadca3c4a6 100644 --- a/translations/de.json +++ b/translations/de.json @@ -22,7 +22,14 @@ "dashboard.records.myLibrary": "", "dashboard.records.myOrg": "", "dashboard.records.myRecords": "", + "dashboard.records.noRecord": "", + "dashboard.records.noUser": "", + "dashboard.records.publishedRecords": "", "dashboard.records.search": "", + "dashboard.records.users": "", + "dashboard.records.username": "", + "dashboard.records.userDetail": "", + "dashboard.records.userEmail": "", "datafeeder.analysisProgressBar.illustration.fileFormatDetection": "Dateiformat-Erkennung", "datafeeder.analysisProgressBar.illustration.gatheringDatasetInformation": "Sammeln von Datensatzinformationen", "datafeeder.analysisProgressBar.illustration.samplingData": "Datenauswahl", @@ -246,6 +253,9 @@ "search.filters.maximize": "Erweitern", "search.filters.minimize": "Minimieren", "search.filters.publicationYear": "Veröffentlichungsjahr", + "search.filters.myRecords": "", + "search.filters.myRecordsHelp": "", + "search.filters.otherRecords": "", "search.filters.publisher": "Organisationen", "search.filters.representationType": "", "search.filters.resourceType": "", diff --git a/translations/en.json b/translations/en.json index a5161f5937..57c5c07b4b 100644 --- a/translations/en.json +++ b/translations/en.json @@ -22,7 +22,14 @@ "dashboard.records.myLibrary": "My library", "dashboard.records.myOrg": "Organization", "dashboard.records.myRecords": "My Records", + "dashboard.records.noRecord": "No record for this organization", + "dashboard.records.noUser": "No users for this organization", + "dashboard.records.publishedRecords": "published records", "dashboard.records.search": "Search for \"{searchText}\"", + "dashboard.records.users": "users", + "dashboard.records.username": "Username", + "dashboard.records.userDetail": "Name", + "dashboard.records.userEmail": "Email", "datafeeder.analysisProgressBar.illustration.fileFormatDetection": "File format \n detection", "datafeeder.analysisProgressBar.illustration.gatheringDatasetInformation": "Gathering dataset \n information", "datafeeder.analysisProgressBar.illustration.samplingData": "Sampling \n data", @@ -246,6 +253,9 @@ "search.filters.maximize": "Expand", "search.filters.minimize": "Minimize", "search.filters.publicationYear": "Publication year", + "search.filters.myRecords": "Show only my records", + "search.filters.myRecordsHelp": "When this is enabled, records only created by myself are shown; records created by others will not show up.", + "search.filters.otherRecords": "Showing records from another person", "search.filters.publisher": "Organizations", "search.filters.representationType": "Representation type", "search.filters.resourceType": "Resource type", diff --git a/translations/es.json b/translations/es.json index 99b30dbc7b..deb48dee7a 100644 --- a/translations/es.json +++ b/translations/es.json @@ -22,7 +22,14 @@ "dashboard.records.myLibrary": "", "dashboard.records.myOrg": "", "dashboard.records.myRecords": "", + "dashboard.records.noRecord": "", + "dashboard.records.noUser": "", + "dashboard.records.publishedRecords": "", "dashboard.records.search": "", + "dashboard.records.users": "", + "dashboard.records.username": "", + "dashboard.records.userDetail": "", + "dashboard.records.userEmail": "", "datafeeder.analysisProgressBar.illustration.fileFormatDetection": "", "datafeeder.analysisProgressBar.illustration.gatheringDatasetInformation": "", "datafeeder.analysisProgressBar.illustration.samplingData": "", @@ -246,6 +253,9 @@ "search.filters.maximize": "", "search.filters.minimize": "", "search.filters.publicationYear": "", + "search.filters.myRecords": "", + "search.filters.myRecordsHelp": "", + "search.filters.otherRecords": "", "search.filters.publisher": "", "search.filters.representationType": "", "search.filters.resourceType": "", diff --git a/translations/fr.json b/translations/fr.json index 3624304b54..b5002eb05d 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -22,7 +22,14 @@ "dashboard.records.myLibrary": "Ma bibliothèque", "dashboard.records.myOrg": "Mon organisation", "dashboard.records.myRecords": "Mes fiches publiées", + "dashboard.records.noRecord": "Aucun jeu de données pour cette organisation", + "dashboard.records.noUser": "Aucun utilisateur pour cette organisation", + "dashboard.records.publishedRecords": "données publiées", "dashboard.records.search": "Résultats pour \"{searchText}\"", + "dashboard.records.users": "utilisateurs", + "dashboard.records.username": "Nom d'utilisateur", + "dashboard.records.userDetail": "Nom", + "dashboard.records.userEmail": "Email", "datafeeder.analysisProgressBar.illustration.fileFormatDetection": "Détection du \n format de fichier", "datafeeder.analysisProgressBar.illustration.gatheringDatasetInformation": "Récupération des informations \n sur le jeu de données", "datafeeder.analysisProgressBar.illustration.samplingData": "Sampling \n des données", @@ -246,6 +253,9 @@ "search.filters.maximize": "Agrandir", "search.filters.minimize": "Réduire", "search.filters.publicationYear": "Année de publication", + "search.filters.myRecords": "Voir mes données", + "search.filters.myRecordsHelp": "Quand activé, n'affiche que les données créées avec mon utilisateur. Les données créées par les autres utilisateurs ne sont pas affichées.", + "search.filters.otherRecords": "Affichage des données d'un autre utilisateur", "search.filters.publisher": "Organisations", "search.filters.representationType": "Type de représentation", "search.filters.resourceType": "Type de ressource", diff --git a/translations/it.json b/translations/it.json index 87e64ddc88..db14efc962 100644 --- a/translations/it.json +++ b/translations/it.json @@ -22,7 +22,14 @@ "dashboard.records.myLibrary": "", "dashboard.records.myOrg": "", "dashboard.records.myRecords": "", + "dashboard.records.noRecord": "", + "dashboard.records.noUser": "", + "dashboard.records.publishedRecords": "", "dashboard.records.search": "", + "dashboard.records.users": "", + "dashboard.records.username": "", + "dashboard.records.userDetail": "", + "dashboard.records.userEmail": "", "datafeeder.analysisProgressBar.illustration.fileFormatDetection": "", "datafeeder.analysisProgressBar.illustration.gatheringDatasetInformation": "", "datafeeder.analysisProgressBar.illustration.samplingData": "", @@ -246,6 +253,9 @@ "search.filters.maximize": "", "search.filters.minimize": "", "search.filters.publicationYear": "", + "search.filters.myRecords": "", + "search.filters.myRecordsHelp": "", + "search.filters.otherRecords": "", "search.filters.publisher": "", "search.filters.representationType": "", "search.filters.resourceType": "", diff --git a/translations/nl.json b/translations/nl.json index 66028d6bbe..4af1cd9267 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -22,7 +22,14 @@ "dashboard.records.myLibrary": "", "dashboard.records.myOrg": "", "dashboard.records.myRecords": "", + "dashboard.records.noRecord": "", + "dashboard.records.noUser": "", + "dashboard.records.publishedRecords": "", "dashboard.records.search": "", + "dashboard.records.users": "", + "dashboard.records.username": "", + "dashboard.records.userDetail": "", + "dashboard.records.userEmail": "", "datafeeder.analysisProgressBar.illustration.fileFormatDetection": "", "datafeeder.analysisProgressBar.illustration.gatheringDatasetInformation": "", "datafeeder.analysisProgressBar.illustration.samplingData": "", @@ -246,6 +253,9 @@ "search.filters.maximize": "", "search.filters.minimize": "", "search.filters.publicationYear": "", + "search.filters.myRecords": "", + "search.filters.myRecordsHelp": "", + "search.filters.otherRecords": "", "search.filters.publisher": "", "search.filters.representationType": "", "search.filters.resourceType": "", diff --git a/translations/pt.json b/translations/pt.json index 42f5e7ebec..d4a4d66612 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -22,7 +22,14 @@ "dashboard.records.myLibrary": "", "dashboard.records.myOrg": "", "dashboard.records.myRecords": "", + "dashboard.records.noRecord": "", + "dashboard.records.noUser": "", + "dashboard.records.publishedRecords": "", "dashboard.records.search": "", + "dashboard.records.users": "", + "dashboard.records.username": "", + "dashboard.records.userDetail": "", + "dashboard.records.userEmail": "", "datafeeder.analysisProgressBar.illustration.fileFormatDetection": "", "datafeeder.analysisProgressBar.illustration.gatheringDatasetInformation": "", "datafeeder.analysisProgressBar.illustration.samplingData": "", @@ -246,6 +253,9 @@ "search.filters.maximize": "", "search.filters.minimize": "", "search.filters.publicationYear": "", + "search.filters.myRecords": "", + "search.filters.myRecordsHelp": "", + "search.filters.otherRecords": "", "search.filters.publisher": "", "search.filters.representationType": "", "search.filters.resourceType": "",