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/checks.yml b/.github/workflows/checks.yml
index f9bc91c370..6090d16a73 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -11,6 +11,7 @@ on:
push:
branches:
- main
+ - develop
pull_request:
types: [opened, synchronize, ready_for_review]
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/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 0000000000..ea8997cd08
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,25 @@
+name: End-to-end tests
+on:
+ push:
+ branches:
+ - main
+ - develop
+ pull_request:
+ types: [opened, synchronize, ready_for_review]
+
+jobs:
+ cypress-run:
+ name: Cypress test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build the backend
+ run: sudo docker-compose -f support-services/docker-compose.yml up -d init
+
+ - name: install dependencies
+ run: |
+ npm ci
+
+ - name: E2E tests
+ run: npx nx run-many --target=e2e
diff --git a/apps/datahub-e2e/src/e2e/datasets.cy.ts b/apps/datahub-e2e/src/e2e/datasets.cy.ts
index 747c81b584..b096d09cec 100644
--- a/apps/datahub-e2e/src/e2e/datasets.cy.ts
+++ b/apps/datahub-e2e/src/e2e/datasets.cy.ts
@@ -270,6 +270,7 @@ describe('datasets', () => {
cy.get('@optionsLabel').should('eql', [
'Agence wallonne du Patrimoine (SPW - Territoire, Logement, Patrimoine, Énergie - Agence wallonne du Patrimoine) (1)',
'atmo Hauts-de-France (1)',
+ 'Barbie Inc. (1)',
'Bundesamt für Raumentwicklung (1)',
"Canton du Valais - Service de l'environnement (SEN) - Protection des sols (1)",
'Cellule informatique et géomatique (SPW - Intérieur et Action sociale - Direction fonctionnelle et d’appui) (1)',
@@ -279,7 +280,7 @@ describe('datasets', () => {
'Géo2France (1)',
"Helpdesk carto du SPW (SPW - Secrétariat général - SPW Digital - Département de la Géomatique - Direction de l'Intégration des géodonnées) (2)",
'Métropole Européenne de Lille (1)',
- 'Région Hauts-de-France (2)',
+ 'Région Hauts-de-France (1)',
'Service public de Wallonie (SPW) (2)',
"Société Publique de Gestion de l'Eau (SPGE) (1)",
])
diff --git a/apps/datahub-e2e/src/e2e/organizations.cy.ts b/apps/datahub-e2e/src/e2e/organizations.cy.ts
index 9182f01df0..106610cfcc 100644
--- a/apps/datahub-e2e/src/e2e/organizations.cy.ts
+++ b/apps/datahub-e2e/src/e2e/organizations.cy.ts
@@ -45,21 +45,21 @@ describe('organizations', () => {
})
it('should display organization information', () => {
cy.get('@organizationsName')
- .eq(2)
+ .eq(3)
.invoke('text')
.should('contain', 'Bundesamt für Raumentwicklung')
cy.get('@organizationsDesc')
- .eq(2)
+ .eq(3)
.invoke('text')
.should('contain', 'Bundesamt für Raumentwicklung')
cy.get('@organizationsRecordsCount')
- .eq(2)
+ .eq(3)
.invoke('text')
.should('contain', '1')
})
it('should display an actual logo', () => {
cy.get('@organizations')
- .eq(8)
+ .eq(9)
.find('img')
.should('have.attr', 'src')
.and('contain', 'G%C3%A9o2France-4-3_decoupe.png')
@@ -124,7 +124,7 @@ describe('organizations', () => {
it('should go to next page with arrow', () => {
cy.then(() => {
cy.get('@pagination').find('[data-cy=next-page]').click()
- cy.get('@organizations').should('have.length', 2)
+ cy.get('@organizations').should('have.length', 3)
})
})
it('should go back to the first page with arrow', () => {
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 22c4fe2e62..5b94900251 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: () => ({
@@ -64,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)
@@ -100,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
@@ -128,6 +137,10 @@ describe('SearchFiltersComponent', () => {
provide: FieldsService,
useClass: FieldsServiceMock,
},
+ {
+ provide: AuthService,
+ useClass: AuthServiceMock,
+ },
],
})
.overrideComponent(SearchFiltersComponent, {
@@ -152,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', () => {
@@ -161,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', () => {
@@ -171,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', () => {
@@ -182,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()
})
})
@@ -198,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 c12ab5b9bf..49d1991ea8 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
@@ -6,6 +6,7 @@ import {
QueryList,
ViewChildren,
} from '@angular/core'
+import { AuthService } from '@geonetwork-ui/api/repository/gn4'
import {
FieldsService,
FilterDropdownComponent,
@@ -13,6 +14,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',
@@ -26,14 +29,28 @@ export class SearchFiltersComponent implements OnInit {
searchConfig: { fieldName: string; title: string }[]
isOpen = false
@Input() isQualitySortable = 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',
@@ -72,6 +89,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/dashboard.cy.ts b/apps/metadata-editor-e2e/src/e2e/dashboard.cy.ts
index 01fcd7a4ef..14a5261f2e 100644
--- a/apps/metadata-editor-e2e/src/e2e/dashboard.cy.ts
+++ b/apps/metadata-editor-e2e/src/e2e/dashboard.cy.ts
@@ -10,26 +10,27 @@ describe('dashboard', () => {
.then((text) => {
pageOne = text
})
- cy.get('gn-ui-pagination-buttons').find('gn-ui-button').last().click()
+ })
+ //TODO remove skip when dump contains more than 15 records
+ it.skip('should display different results on click on specific page and change url', () => {
+ cy.visit('/records/search?_page=2')
+ cy.get('gn-ui-pagination-buttons').find('gn-ui-button').eq(1).click()
cy.get('gn-ui-record-table')
.find('.record-table-col')
.first()
.invoke('text')
.then((text) => {
- expect(text).not.to.equal(pageOne)
- cy.url().should('include', 'page=2')
+ expect(text).to.equal(pageOne)
+ cy.url().should('include', 'page=1')
})
- })
- it('should display different results on click on specific page and change url', () => {
- cy.visit('/records/search?_page=2')
- cy.get('gn-ui-pagination-buttons').find('gn-ui-button').eq(1).click()
+ cy.get('gn-ui-pagination-buttons').find('gn-ui-button').last().click()
cy.get('gn-ui-record-table')
.find('.record-table-col')
.first()
.invoke('text')
.then((text) => {
- expect(text).to.equal(pageOne)
- cy.url().should('include', 'page=1')
+ expect(text).not.to.equal(pageOne)
+ cy.url().should('include', 'page=2')
})
})
})
@@ -46,6 +47,9 @@ describe('dashboard', () => {
.then((list) => {
originalFirstItem = list.trim()
cy.get('.record-table-header').first().click()
+ // Takes time to refresh results
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ // cy.wait(500)
cy.get('gn-ui-record-table')
.find('.record-table-col')
.first()
@@ -61,4 +65,38 @@ describe('dashboard', () => {
})
})
})
+
+ describe('checkboxes', () => {
+ it('should show the correct amount of selected records when they are selected', () => {
+ cy.visit('/records/all')
+ cy.get('gn-ui-record-table')
+ .find('.record-table-col')
+ .get('[type="checkbox"]')
+ .eq(2)
+ .click()
+ cy.get('.selected-records').contains('1 selected')
+ })
+
+ it('should show nothing when none are selected', () => {
+ cy.visit('/records/all')
+ cy.get('gn-ui-record-table')
+ .find('.record-table-col')
+ .get('mat-checkbox.mat-primary')
+ .each(($checkbox) => cy.wrap($checkbox).click())
+ cy.get('.records-information').should(
+ 'not.have.descendants',
+ '.selected-records'
+ )
+ })
+
+ it('should select all records when the "select all" checkbox is checked', () => {
+ cy.visit('/records/all')
+ cy.get('gn-ui-record-table')
+ .find('.record-table-col')
+ .get('mat-checkbox.mat-primary')
+ .first()
+ .click()
+ cy.get('.selected-records').contains('12 selected')
+ })
+ })
})
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..620a5b5fff
--- /dev/null
+++ b/apps/metadata-editor-e2e/src/e2e/my-org.cy.ts
@@ -0,0 +1,53 @@
+describe('my-org', () => {
+ beforeEach(() => {
+ cy.loginGN('barbie', 'p4ssworD_', false)
+ cy.intercept({
+ method: 'GET',
+ url: 'http://localhost:4200/geonetwork/srv/api/userselections/0/101',
+ }).as('dataGetFirst')
+ cy.visit(`/records/my-org`)
+ cy.get('md-editor-dashboard-menu').find('a').first().click()
+ cy.wait('@dataGetFirst').its('response.statusCode').should('equal', 200)
+ cy.get('main').children('div').first().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')
+ .should('have.attr', 'href')
+ .then((href) => {
+ expect(href).to.include('search?publisher=Barbie+Inc')
+ })
+ })
+ 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-e2e/src/support/commands.ts b/apps/metadata-editor-e2e/src/support/commands.ts
index dc92994920..85cc534619 100644
--- a/apps/metadata-editor-e2e/src/support/commands.ts
+++ b/apps/metadata-editor-e2e/src/support/commands.ts
@@ -27,18 +27,14 @@ declare namespace Cypress {
Cypress.Commands.add(
'loginGN',
(username: string, password: string, redirect = true) => {
- cy.visit('http://localhost:8080/geonetwork/srv/eng/catalog.search#/home')
- cy.get('.cookie-warning-actions').then(($cookie) => {
- if ($cookie.is(':visible')) {
- $cookie.find('button').eq(0).click()
- cy.scrollTo('top')
- }
+ Cypress.on('uncaught:exception', (err) => {
+ if (err.message.includes('Jsonix')) return false
+ if (err.message.includes('postMessage')) return false
})
- cy.wait(250)
- cy.get('li.signin-dropdown').click()
- cy.get('#inputUsername').type(username)
- cy.get('#inputPassword').type(password)
+ cy.visit('/geonetwork/srv/eng/catalog.signin?debug') // this will point to a 404
+ cy.get('#inputUsername').type(username, { force: true })
+ cy.get('#inputPassword').type(password, { force: true })
cy.get('[name="gnSigninForm"]').submit()
if (redirect) cy.visit('/')
}
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..d30ab13252 100644
--- a/apps/metadata-editor/src/app/records/records-list.component.html
+++ b/apps/metadata-editor/src/app/records/records-list.component.html
@@ -1,17 +1,73 @@
-
-
{{ title }}
+
+
+ dashboard.records.noUser
+ dashboard.records.noRecord
+
()
+ @Output() recordClick = new EventEmitter
()
+ @Output() recordsSelect = new EventEmitter()
}
@Component({
@@ -55,12 +58,18 @@ class RouterMock {
navigate = jest.fn()
}
+class SelectionServiceMock {
+ selectRecords = jest.fn()
+ deselectRecords = jest.fn()
+ clearSelection = jest.fn()
+}
+
describe('RecordsListComponent', () => {
let component: RecordsListComponent
let fixture: ComponentFixture
let router: Router
let searchService: SearchService
- let searchFacade: SearchFacade
+ let selectionService: SelectionService
beforeEach(() => {
TestBed.configureTestingModule({
@@ -77,6 +86,10 @@ describe('RecordsListComponent', () => {
provide: SearchService,
useClass: SearchServiceMock,
},
+ {
+ provide: SelectionService,
+ useClass: SelectionServiceMock,
+ },
],
}).overrideComponent(RecordsListComponent, {
set: {
@@ -90,7 +103,7 @@ describe('RecordsListComponent', () => {
})
router = TestBed.inject(Router)
searchService = TestBed.inject(SearchService)
- searchFacade = TestBed.inject(SearchFacade)
+ selectionService = TestBed.inject(SelectionService)
fixture = TestBed.createComponent(RecordsListComponent)
component = fixture.componentInstance
fixture.detectChanges()
@@ -119,13 +132,33 @@ describe('RecordsListComponent', () => {
expect(pagination.totalPages).toEqual(totalPages)
})
describe('when click on a record', () => {
+ const uniqueIdentifier = 123
+ const singleRecord = {
+ ...DATASET_RECORDS[0],
+ uniqueIdentifier,
+ }
beforeEach(() => {
- table.recordSelect.emit({ uniqueIdentifier: 123 })
+ table.recordClick.emit(singleRecord)
})
it('routes to record edition', () => {
expect(router.navigate).toHaveBeenCalledWith(['/edit', 123])
})
})
+ describe('when selecting a record', () => {
+ const uniqueIdentifier = 123
+ const singleRecord = {
+ ...DATASET_RECORDS[0],
+ uniqueIdentifier,
+ }
+ beforeEach(() => {
+ table.recordsSelect.emit([singleRecord])
+ })
+ it('persists selection', () => {
+ expect(selectionService.selectRecords).toHaveBeenCalledWith([
+ singleRecord,
+ ])
+ })
+ })
describe('when click on pagination', () => {
beforeEach(() => {
pagination.newCurrentPageEvent.emit(3)
diff --git a/apps/metadata-editor/src/app/records/records-list.component.ts b/apps/metadata-editor/src/app/records/records-list.component.ts
index b76a8b65c7..4e5c033613 100644
--- a/apps/metadata-editor/src/app/records/records-list.component.ts
+++ b/apps/metadata-editor/src/app/records/records-list.component.ts
@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'
-import { Component, Input, OnInit } from '@angular/core'
+import { Component, Input } from '@angular/core'
import { MatIconModule } from '@angular/material/icon'
import { Router } from '@angular/router'
import { CatalogRecord } from '@geonetwork-ui/common/domain/record'
@@ -7,6 +7,9 @@ import { SearchFacade, SearchService } from '@geonetwork-ui/feature/search'
import { UiSearchModule } from '@geonetwork-ui/ui/search'
import { UiElementsModule } from '@geonetwork-ui/ui/elements'
import { SortByField } from '@geonetwork-ui/common/domain/search'
+import { TranslateModule } from '@ngx-translate/core'
+import { SelectionService } from '@geonetwork-ui/api/repository/gn4'
+import { Subject } from 'rxjs'
const includes = [
'uuid',
@@ -17,6 +20,7 @@ const includes = [
'cl_status',
'isPublishedToAll',
'link',
+ 'owner',
]
@Component({
@@ -24,19 +28,35 @@ const includes = [
templateUrl: './records-list.component.html',
styleUrls: ['./records-list.component.css'],
standalone: true,
- imports: [CommonModule, MatIconModule, UiSearchModule, UiElementsModule],
+ imports: [
+ CommonModule,
+ MatIconModule,
+ UiSearchModule,
+ UiElementsModule,
+ TranslateModule,
+ ],
})
export class RecordsListComponent {
@Input() title: string
+ @Input() logo: string
+ @Input() recordCount: number
+ @Input() userCount: number
+ @Input() users
+ @Input() linkToDatahub?: string
constructor(
private router: Router,
public searchFacade: SearchFacade,
- public searchService: SearchService
+ public searchService: SearchService,
+ private selectionService: SelectionService
) {
this.searchFacade.setPageSize(15).setConfigRequestFields(includes)
}
+ getRecords() {
+ return this.users ? this.users : this.searchFacade.results$
+ }
+
paginate(page: number) {
this.searchService.setPage(page)
}
@@ -51,4 +71,20 @@ export class RecordsListComponent {
setSortBy(newSortBy: SortByField) {
this.searchService.setSortBy(newSortBy)
}
+
+ showUsers() {
+ this.router.navigate(['/users/my-org'])
+ }
+
+ getSelectedRecords() {
+ return this.selectionService.selectedRecordsIdentifiers$
+ }
+
+ handleRecordsSelection(records: CatalogRecord[]) {
+ this.selectionService.selectRecords(records).subscribe()
+ }
+
+ handleRecordsDeselection(records: CatalogRecord[]) {
+ this.selectionService.deselectRecords(records).subscribe()
+ }
}
diff --git a/apps/metadata-editor/src/app/router.service.ts b/apps/metadata-editor/src/app/router.service.ts
index a15fbafc9b..5a1b48121d 100644
--- a/apps/metadata-editor/src/app/router.service.ts
+++ b/apps/metadata-editor/src/app/router.service.ts
@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { appRoutes } from './app.routes'
+import { getGlobalConfig } from '@geonetwork-ui/util/app-config'
@Injectable({
providedIn: 'root',
@@ -15,4 +16,11 @@ export class EditorRouterService {
getSearchRoute(): string {
return 'records/search'
}
+
+ getDatahubSearchRoute(): string {
+ return new URL(
+ `${getGlobalConfig().DATAHUB_URL}/search`,
+ window.location.toString()
+ ).toString()
+ }
}
diff --git a/conf/default.toml b/conf/default.toml
index e704ab5878..e47ec6f9af 100644
--- a/conf/default.toml
+++ b/conf/default.toml
@@ -6,6 +6,7 @@
[global]
# This URL (relative or absolute) must point to the API endpoint of a GeoNetwork4 instance
geonetwork4_api_url = "/geonetwork/srv/api"
+datahub_url = "/datahub"
# This should point to a proxy to avoid CORS errors on some requests (data preview, OGC capabilities etc.)
# The actual URL will be appended after this path, e.g. : https://my.proxy/?url=http%3A%2F%2Fencoded.url%2Fows`
# This is an optional parameter: leave empty to disable proxy usage
@@ -72,7 +73,7 @@ background_color = "#fdfbff"
# filter_geometry_data = '{ "coordinates": [...], "type": "Polygon" }'
# The advanced search filters available to the user can be customized with this setting.
-# The following fields can be used for filtering: 'publisher', 'format', 'publicationYear', 'documentStandard', 'inspireKeyword', 'topic', 'isSpatial', 'license'
+# The following fields can be used for filtering: 'publisher', 'format', 'publicationYear', 'standard', 'inspireKeyword', 'topic', 'isSpatial', 'license'
# any other field will be ignored
# advanced_filters = ['publisher', 'format', 'publicationYear', 'topic', 'isSpatial', 'license']
diff --git a/docs/apps/editor.md b/docs/apps/editor.md
index e53d148ff2..0e67017ee6 100644
--- a/docs/apps/editor.md
+++ b/docs/apps/editor.md
@@ -4,6 +4,17 @@ outline: deep
# Editor
-## Chapter 1
+## My organization
+
+The "my organization" tab contains filtered records owned by the organization of the logged in user. Note that this page will not display any records if no user is logged in.
+The page is made of :
+
+- The organization name and logo, fetched from `organisations$` in the `OrganizationServiceInterface`.
+- A table with the filtered records. The table is from the component `md-editor-records-list`, which does the fetching of the records.
+- Two links :
+ - The first link is the count of published records for this organization. It leads to the datahub, where the filter by organization will be activated to only show the user's organization. The filter is set through the URL directly with the name from `organisations$`.
+ - The second link is the count of users for this organization. It leads to a new page in the dashboard. The page is also made of the organization's name and logo, and of a table presenting the users and their details. These users are fetched from the observables `user$` (logged in user), and `allUsers$` (all users of geonetwork) in the `AuthService`. `allUsers$` are then filtered by their organization to be displayed here. The table in this page is also from the component `md-editor-records-list`, which detects if an input `users` (containing the list of filtered users) was received and creates the table accordingly.
+
+It's important to know that a user with an organization must be logged in for this component to work. If not, or in the case where the organization doesn't own any records, a message will be displayed instead of the table, to inform the user.
## Chapter 2
diff --git a/libs/api/repository/src/lib/gn4/auth/auth.service.spec.ts b/libs/api/repository/src/lib/gn4/auth/auth.service.spec.ts
index cf31d96fc7..c587381c57 100644
--- a/libs/api/repository/src/lib/gn4/auth/auth.service.spec.ts
+++ b/libs/api/repository/src/lib/gn4/auth/auth.service.spec.ts
@@ -4,6 +4,7 @@ import { TestBed } from '@angular/core/testing'
import { MeApiService } from '@geonetwork-ui/data-access/gn4'
import { TranslateService } from '@ngx-translate/core'
import { AvatarServiceInterface } from './avatar.service.interface'
+import { HttpClientTestingModule } from '@angular/common/http/testing'
const userMock = {
id: '21737',
@@ -64,6 +65,7 @@ describe('AuthService', () => {
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 06ee1aea0e..e254f546e4 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
@@ -115,26 +115,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',
@@ -149,11 +154,6 @@ describe('ElasticsearchService', () => {
query: 'hello',
},
},
- {
- query_string: {
- query: '(Org:"world")',
- },
- },
],
must_not: {
terms: {
@@ -176,14 +176,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',
@@ -198,16 +209,6 @@ describe('ElasticsearchService', () => {
query: 'hello',
},
},
- {
- query_string: {
- query: '(Org:"world")',
- },
- },
- {
- ids: {
- values: ['record-1', 'record-2', 'record-3'],
- },
- },
],
must_not: {
terms: {
@@ -222,6 +223,10 @@ describe('ElasticsearchService', () => {
{
Org: {
world: true,
+ world2: true,
+ },
+ name: {
+ john: true,
},
any: 'hello',
},
@@ -230,14 +235,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',
@@ -252,9 +268,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")',
},
},
{
@@ -263,6 +308,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'],
@@ -283,7 +383,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`
)
})
@@ -306,9 +406,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',
},
{},
@@ -317,13 +415,19 @@ describe('ElasticsearchService', () => {
)
expect(query).toEqual({
bool: {
- filter: [],
- must: [
+ filter: [
{
terms: {
isTemplate: ['n'],
},
},
+ {
+ query_string: {
+ query: 'Org:(world)',
+ },
+ },
+ ],
+ must: [
{
query_string: {
default_operator: 'AND',
@@ -338,11 +442,6 @@ describe('ElasticsearchService', () => {
query: 'hello',
},
},
- {
- query_string: {
- query: '(Org:"world")',
- },
- },
],
must_not: {
terms: {
@@ -703,6 +802,7 @@ describe('ElasticsearchService', () => {
filters: {
filter1: { field1: '100' },
filter2: { field2: { value1: true, value3: true } },
+ filter3: 'my own query',
},
},
myHistogram: {
@@ -715,14 +815,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 3bdd0b7c0a..c5981a1e43 100644
--- a/libs/api/repository/src/lib/gn4/elasticsearch/elasticsearch.service.ts
+++ b/libs/api/repository/src/lib/gn4/elasticsearch/elasticsearch.service.ts
@@ -9,6 +9,8 @@ import {
Aggregation,
AggregationParams,
AggregationsParams,
+ FieldFilter,
+ FieldFilters,
FilterAggregationParams,
SortByField,
} from '@geonetwork-ui/common/domain/search'
@@ -211,17 +213,36 @@ export class ElasticsearchService {
return this.metadataLang === 'current'
}
+ 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',
@@ -231,6 +252,10 @@ export class ElasticsearchService {
]),
}
const should = [] as Record[]
+ const filter = [this.queryFilterOnValues('isTemplate', 'n')] as Record<
+ string,
+ unknown
+ >[]
if (any) {
must.push({
@@ -241,15 +266,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,
},
@@ -283,7 +309,7 @@ export class ElasticsearchService {
must,
must_not,
should,
- filter: [],
+ filter,
},
}
}
@@ -376,6 +402,7 @@ export class ElasticsearchService {
* }
* }
*/
+ // FIXME: this is not used anymore
stateFiltersToQueryString(facetsState) {
const query = []
for (const indexKey in facetsState) {
@@ -393,6 +420,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') {
@@ -444,20 +472,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/index.ts b/libs/api/repository/src/lib/gn4/index.ts
index c1ec26cc35..bfbbbd4fee 100644
--- a/libs/api/repository/src/lib/gn4/index.ts
+++ b/libs/api/repository/src/lib/gn4/index.ts
@@ -4,3 +4,4 @@ export * from './elasticsearch'
export * from './settings/gn4-settings.service'
export * from './auth'
export * from './favorites/favorites.service'
+export * from './selection/selection.service'
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/api/repository/src/lib/gn4/selection/selection.service.spec.ts b/libs/api/repository/src/lib/gn4/selection/selection.service.spec.ts
new file mode 100644
index 0000000000..8df5a3f655
--- /dev/null
+++ b/libs/api/repository/src/lib/gn4/selection/selection.service.spec.ts
@@ -0,0 +1,105 @@
+import { SelectionsApiService } from '@geonetwork-ui/data-access/gn4'
+import { SelectionService } from './selection.service'
+import { firstValueFrom, of } from 'rxjs'
+import { CatalogRecord } from '@geonetwork-ui/common/domain/record'
+
+function record(uuid: string): CatalogRecord {
+ return {
+ uniqueIdentifier: uuid,
+ } as CatalogRecord
+}
+
+class SelectionsServiceMock {
+ private selected = ['001', '002', '003']
+ add = jest.fn((bucket, ids) => {
+ this.selected.push(...ids)
+ return of(undefined)
+ })
+ clear = jest.fn((bucket, ids) => {
+ this.selected = this.selected.filter(
+ (id) => !!ids && ids.indexOf(id) === -1
+ )
+ return of(undefined)
+ })
+ get = jest.fn(() => of(this.selected))
+}
+
+describe('SelectionService', () => {
+ let service: SelectionService
+ let selectionsService: SelectionsApiService
+
+ beforeEach(async () => {
+ selectionsService = new SelectionsServiceMock() as any
+ service = new SelectionService(selectionsService)
+ })
+
+ it('should be created', () => {
+ expect(service).toBeTruthy()
+ })
+
+ describe('#selectRecords', () => {
+ let selectedRecords
+ beforeEach(async () => {
+ service.selectedRecordsIdentifiers$.subscribe((value) => {
+ selectedRecords = value
+ })
+ await firstValueFrom(
+ service.selectRecords([record('abcd'), record('efgh'), record('001')])
+ )
+ })
+ it('calls the corresponding API', () => {
+ expect(selectionsService.add).toHaveBeenCalledWith('gnui', [
+ 'abcd',
+ 'efgh',
+ '001',
+ ])
+ })
+ it('emits new records in selectedRecordsIdentifiers$', () => {
+ expect(selectedRecords).toEqual(['001', '002', '003', 'abcd', 'efgh'])
+ })
+ })
+
+ describe('#deselectRecords', () => {
+ let selectedRecords
+ beforeEach(async () => {
+ service.selectedRecordsIdentifiers$.subscribe((value) => {
+ selectedRecords = value
+ })
+ await firstValueFrom(
+ service.deselectRecords([record('abcd'), record('efgh'), record('001')])
+ )
+ })
+ it('calls the corresponding API', () => {
+ expect(selectionsService.clear).toHaveBeenCalledWith('gnui', [
+ 'abcd',
+ 'efgh',
+ '001',
+ ])
+ })
+ it('emits new records in selectedRecordsIdentifiers$', () => {
+ expect(selectedRecords).toEqual(['002', '003'])
+ })
+ })
+
+ describe('#clearSelection', () => {
+ let selectedRecords
+ beforeEach(async () => {
+ service.selectedRecordsIdentifiers$.subscribe((value) => {
+ selectedRecords = value
+ })
+ await firstValueFrom(service.clearSelection())
+ })
+ it('calls the corresponding API', () => {
+ expect(selectionsService.get).toHaveBeenCalledWith('gnui')
+
+ expect(selectionsService.clear).toHaveBeenCalledWith('gnui', [
+ '001',
+ '002',
+ '003',
+ ])
+ })
+ it('emits new records in selectedRecordsIdentifiers$', () => {
+ expect(selectedRecords).toEqual([])
+ })
+ })
+})
diff --git a/libs/api/repository/src/lib/gn4/selection/selection.service.ts b/libs/api/repository/src/lib/gn4/selection/selection.service.ts
new file mode 100644
index 0000000000..20113654a7
--- /dev/null
+++ b/libs/api/repository/src/lib/gn4/selection/selection.service.ts
@@ -0,0 +1,80 @@
+import { Injectable } from '@angular/core'
+import { CatalogRecord } from '@geonetwork-ui/common/domain/record'
+import { SelectionsApiService } from '@geonetwork-ui/data-access/gn4'
+import { BehaviorSubject, Observable, Subscription, map, tap } from 'rxjs'
+
+const BUCKET_ID = 'gnui'
+
+@Injectable({
+ providedIn: 'root',
+})
+export class SelectionService {
+ selectedRecordsIdentifiers$: BehaviorSubject = new BehaviorSubject(
+ []
+ )
+ subscription: Subscription
+
+ constructor(private selectionsApi: SelectionsApiService) {
+ this.selectionsApi.get(BUCKET_ID).subscribe((selectedIds) => {
+ this.addIdsToSelected(Array.from(selectedIds))
+ })
+ }
+
+ private addIdsToSelected(ids: string[]) {
+ const currentIds = this.selectedRecordsIdentifiers$.value
+ const uniqueSet = new Set([...currentIds, ...ids])
+ this.selectedRecordsIdentifiers$.next([...uniqueSet])
+ }
+
+ private removeIdsFromSelected(ids: string[]) {
+ const filtered = this.selectedRecordsIdentifiers$.value.filter(
+ (value) => !ids.includes(value)
+ )
+ this.selectedRecordsIdentifiers$.next(filtered)
+ }
+
+ selectRecords(records: CatalogRecord[]): Observable {
+ const newIds = []
+ records.map((record) => {
+ newIds.push(record.uniqueIdentifier)
+ })
+ const apiResponse = this.selectionsApi.add(BUCKET_ID, newIds)
+ return apiResponse.pipe(
+ tap(() => {
+ this.addIdsToSelected(newIds)
+ }),
+ map(() => undefined)
+ )
+ }
+
+ deselectRecords(records: CatalogRecord[]): Observable {
+ const idsToBeRemoved = []
+ records.map((record) => {
+ idsToBeRemoved.push(record.uniqueIdentifier)
+ })
+ const apiResponse = this.selectionsApi.clear(BUCKET_ID, idsToBeRemoved)
+ return apiResponse.pipe(
+ tap(() => {
+ this.removeIdsFromSelected(idsToBeRemoved)
+ }),
+ map(() => undefined)
+ )
+ }
+
+ clearSelection(): Observable {
+ const currentSelectedResponse = this.selectionsApi.get(BUCKET_ID)
+ let currentSelection
+ this.subscription = currentSelectedResponse.subscribe((value) => {
+ currentSelection = [...value]
+ })
+ this.selectionsApi.clear(BUCKET_ID, currentSelection)
+ const apiResponse = this.selectionsApi.clear(BUCKET_ID, currentSelection)
+
+ return apiResponse.pipe(
+ tap(() => {
+ this.removeIdsFromSelected(currentSelection)
+ }),
+ map(() => undefined)
+ )
+ }
+}
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 21059b6fb2..efa22c7852 100644
--- a/libs/feature/catalog/src/index.ts
+++ b/libs/feature/catalog/src/index.ts
@@ -5,3 +5,4 @@ export * from './lib/records/records.service'
export * from './lib/organisations/organisations.component'
export * from './lib/site-title/site-title.component'
export * from './lib/source-label/source-label.component'
+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..1161cfa0c8
--- /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?.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/inputs/src/lib/checkbox/checkbox.component.css b/libs/ui/inputs/src/lib/checkbox/checkbox.component.css
new file mode 100644
index 0000000000..25929c14ff
--- /dev/null
+++ b/libs/ui/inputs/src/lib/checkbox/checkbox.component.css
@@ -0,0 +1,24 @@
+.default {
+ --gn-ui-checkbox-color: var(--color-main);
+}
+
+.secondary {
+ --gn-ui-checkbox-color: var(--color-secondary);
+}
+
+.primary {
+ --gn-ui-checkbox-color: var(--color-primary);
+}
+
+mat-checkbox {
+ --mdc-checkbox-selected-icon-color: var(--gn-ui-checkbox-color);
+ --mdc-checkbox-selected-focus-icon-color: var(--gn-ui-checkbox-color);
+ --mdc-checkbox-selected-hover-icon-color: var(--gn-ui-checkbox-color);
+ --mdc-checkbox-selected-icon-color: var(--gn-ui-checkbox-color);
+ --mdc-checkbox-selected-pressed-icon-color: var(--gn-ui-checkbox-color);
+ --mdc-checkbox-selected-focus-state-layer-color: var(--gn-ui-checkbox-color);
+ --mdc-checkbox-selected-hover-state-layer-color: var(--gn-ui-checkbox-color);
+ --mdc-checkbox-selected-pressed-state-layer-color: var(
+ --gn-ui-checkbox-color
+ );
+}
diff --git a/libs/ui/inputs/src/lib/checkbox/checkbox.component.html b/libs/ui/inputs/src/lib/checkbox/checkbox.component.html
new file mode 100644
index 0000000000..db748c0e12
--- /dev/null
+++ b/libs/ui/inputs/src/lib/checkbox/checkbox.component.html
@@ -0,0 +1,8 @@
+
diff --git a/libs/ui/inputs/src/lib/checkbox/checkbox.component.spec.ts b/libs/ui/inputs/src/lib/checkbox/checkbox.component.spec.ts
new file mode 100644
index 0000000000..384c47f8d7
--- /dev/null
+++ b/libs/ui/inputs/src/lib/checkbox/checkbox.component.spec.ts
@@ -0,0 +1,47 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { CheckboxComponent } from './checkbox.component'
+import { MatCheckboxModule } from '@angular/material/checkbox'
+
+describe('CheckboxComponent', () => {
+ let component: CheckboxComponent
+ let fixture: ComponentFixture
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [CheckboxComponent],
+ imports: [MatCheckboxModule],
+ }).compileComponents()
+
+ fixture = TestBed.createComponent(CheckboxComponent)
+ component = fixture.componentInstance
+ fixture.detectChanges()
+ })
+
+ it('should create', () => {
+ expect(component).toBeTruthy()
+ })
+
+ describe('click when unchecked', () => {
+ beforeEach(() => {
+ component.checked = false
+ })
+ it('should invert checked state when being clicked', () => {
+ const event = new Event('click')
+ component.handleClick(event)
+
+ expect(component.checked).toBe(true)
+ })
+ })
+
+ describe('click when checked', () => {
+ beforeEach(() => {
+ component.checked = true
+ })
+ it('should invert checked state when being clicked', () => {
+ const event = new Event('click')
+ component.handleClick(event)
+
+ expect(component.checked).toBe(false)
+ })
+ })
+})
diff --git a/libs/ui/inputs/src/lib/checkbox/checkbox.component.stories.ts b/libs/ui/inputs/src/lib/checkbox/checkbox.component.stories.ts
new file mode 100644
index 0000000000..abd0215d90
--- /dev/null
+++ b/libs/ui/inputs/src/lib/checkbox/checkbox.component.stories.ts
@@ -0,0 +1,46 @@
+import {
+ applicationConfig,
+ Meta,
+ moduleMetadata,
+ StoryObj,
+} from '@storybook/angular'
+
+import { BrowserModule } from '@angular/platform-browser'
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
+import { CommonModule } from '@angular/common'
+import { importProvidersFrom } from '@angular/core'
+import { CheckboxComponent } from './checkbox.component'
+import { MatCheckbox, MatCheckboxModule } from '@angular/material/checkbox'
+
+export default {
+ title: 'Inputs/CheckboxComponent',
+ component: CheckboxComponent,
+ decorators: [
+ moduleMetadata({
+ declarations: [MatCheckbox],
+ imports: [],
+ }),
+ applicationConfig({
+ providers: [
+ importProvidersFrom(
+ BrowserModule,
+ BrowserAnimationsModule,
+ CommonModule,
+ MatCheckboxModule
+ ),
+ ],
+ }),
+ ],
+} as Meta
+
+export const Primary: StoryObj = {
+ args: {
+ checked: false,
+ indeterminate: false,
+ },
+ argTypes: {
+ changed: {
+ action: 'changed',
+ },
+ },
+}
diff --git a/libs/ui/inputs/src/lib/checkbox/checkbox.component.ts b/libs/ui/inputs/src/lib/checkbox/checkbox.component.ts
new file mode 100644
index 0000000000..27051d894e
--- /dev/null
+++ b/libs/ui/inputs/src/lib/checkbox/checkbox.component.ts
@@ -0,0 +1,30 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ EventEmitter,
+ Input,
+ Output,
+} from '@angular/core'
+
+@Component({
+ selector: 'gn-ui-checkbox',
+ templateUrl: './checkbox.component.html',
+ styleUrls: ['./checkbox.component.css'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CheckboxComponent {
+ @Input() type: 'primary' | 'secondary' | 'default' = 'default'
+ @Input() checked = false
+ @Input() indeterminate? = false
+ @Output() changed = new EventEmitter()
+
+ get classList() {
+ return `${this.type}`
+ }
+
+ handleClick(event: Event) {
+ event.stopPropagation()
+ this.checked = !this.checked
+ this.changed.emit(this.checked)
+ }
+}
diff --git a/libs/ui/inputs/src/lib/ui-inputs.module.ts b/libs/ui/inputs/src/lib/ui-inputs.module.ts
index a5c5e02445..a595dbf425 100644
--- a/libs/ui/inputs/src/lib/ui-inputs.module.ts
+++ b/libs/ui/inputs/src/lib/ui-inputs.module.ts
@@ -34,6 +34,7 @@ import { CheckToggleComponent } from './check-toggle/check-toggle.component'
import { CopyTextButtonComponent } from './copy-text-button/copy-text-button.component'
import { MatTooltipModule } from '@angular/material/tooltip'
import { CommonModule } from '@angular/common'
+import { CheckboxComponent } from './checkbox/checkbox.component'
@NgModule({
declarations: [
@@ -58,6 +59,7 @@ import { CommonModule } from '@angular/common'
FormFieldTemporalExtentComponent,
CheckToggleComponent,
CopyTextButtonComponent,
+ CheckboxComponent,
],
imports: [
CommonModule,
@@ -89,6 +91,7 @@ import { CommonModule } from '@angular/common'
FormFieldComponent,
CheckToggleComponent,
CopyTextButtonComponent,
+ CheckboxComponent,
],
})
export class UiInputsModule {}
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 e93f1977c4..6db4a69c18 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
@@ -1,24 +1,76 @@
+
+
- results.records.hits.displayedOn
+
+
+
+
+
+
+
+ {{ record.name }}
+
+
+ {{ record.username }}
+
+
+ {{ record.emailAddresses[0] }}
+
+