diff --git a/apps/datahub-e2e/cypress/support/commands.js b/apps/datahub-e2e/cypress/support/commands.js deleted file mode 100644 index 119ab03f7c..0000000000 --- a/apps/datahub-e2e/cypress/support/commands.js +++ /dev/null @@ -1,25 +0,0 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) diff --git a/apps/datahub-e2e/cypress/support/e2e.js b/apps/datahub-e2e/cypress/support/e2e.js deleted file mode 100644 index d1dd1353e8..0000000000 --- a/apps/datahub-e2e/cypress/support/e2e.js +++ /dev/null @@ -1,20 +0,0 @@ -// *********************************************************** -// This example support/e2e.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import './commands' - -// Alternatively you can use CommonJS syntax: -// require('./commands') diff --git a/apps/datahub-e2e/src/e2e/datasets.cy.ts b/apps/datahub-e2e/src/e2e/datasets.cy.ts index 67833d9677..20a46593ca 100644 --- a/apps/datahub-e2e/src/e2e/datasets.cy.ts +++ b/apps/datahub-e2e/src/e2e/datasets.cy.ts @@ -1,13 +1,26 @@ -/* eslint-disable cypress/no-unnecessary-waiting */ import 'cypress-real-events' describe('datasets', () => { beforeEach(() => { + cy.clearCookies() cy.visit('/search') cy.viewport(1700, 1200) + + // aliases + cy.get('gn-ui-results-list-item').find('a').as('results') + cy.get('@results').first().as('firstResult') + cy.get('@results') + .then(($results) => $results.length) + .as('resultsCount') + cy.get('gn-ui-filter-dropdown').as('filters') + cy.get('gn-ui-sort-by').as('sortBy') + cy.get('[data-cy="addMoreBtn"]').as('addMoreBtn') }) describe('general display', () => { + beforeEach(() => { + cy.get('@addMoreBtn').click() // show all results + }) it('should select the right tab', () => { cy.get('datahub-navigation-menu') .find('button') @@ -15,614 +28,403 @@ describe('datasets', () => { .invoke('attr', 'ng-reflect-ng-class') .should('eq', 'decoration-primary') }) - it('should display the news feed with a few news', () => { - cy.get('gn-ui-results-list-item').should('have.length.gt', 0) - cy.get('gn-ui-results-list-item').should('have.length.lt', 11) + it('should display more than 10 results', () => { + cy.get('@results').should('have.length.above', 10) }) - it('should display four filter buttons', () => { + it('should display the results as rows', () => { + cy.get('gn-ui-results-list-item') + .first() + .find('gn-ui-record-preview-row') + .should('have.length', 1) + }) + it('should only display two filters initially and an expand button', () => { + cy.get('@filters').filter(':visible').should('have.length', 2) cy.get('datahub-search-filters') - .find('gn-ui-filter-dropdown') + .find('[data-cy=filters-expand]') .filter(':visible') - .should('have.length', 2) - cy.get('datahub-search-filters') - .children('div') - .children('div') - .eq(1) - .find('gn-ui-button') - cy.get('datahub-search-filters').find('gn-ui-sort-by') + .should('have.length', 1) }) - it('should display the "add more" button', () => { - cy.get('[data-cy="addMoreBtn"]') + it('should sort by relevance initially', () => { + cy.get('@sortBy') + .find('option:checked') + .invoke('val') + .should('equal', 'desc,_score') }) }) describe('display of dataset previews', () => { - beforeEach(() => { - cy.get('gn-ui-record-preview-row').children('a').first().as('dataset') - }) - it('should display the image', () => { - cy.get('@dataset').find('gn-ui-thumbnail').should('be.visible') + it('should display a logo for first and a placeholder for second result', () => { + cy.get('@sortBy').find('select').select('desc,createDate') // this makes the order reliable + cy.get('@firstResult') + .find('gn-ui-thumbnail') + .children('div') + .invoke('attr', 'data-cy-is-placeholder') + .should('equal', 'false') + cy.get('@firstResult') + .find('gn-ui-thumbnail') + .find('img') + .invoke('attr', 'src') + .should( + 'eql', + 'https://www.geocat.ch/geonetwork/srv/api/records/a8b5e6c0-c21d-4c32-b8f9-10830215890a/attachments/SEN_CartesThematiquesSols.png' + ) + cy.get('@results') + .eq(1) + .find('gn-ui-thumbnail') + .children('div') + .invoke('attr', 'data-cy-is-placeholder') + .should('equal', 'true') }) it('should display the title', () => { - cy.get('@dataset') - .find('div') - .filter('[data-cy="recordTitle"]') + cy.get('@firstResult') + .find('[data-cy="recordTitle"]') .should('be.visible') }) it('should display the summary', () => { - cy.get('@dataset') - .find('div') - .filter('[data-cy="recordAbstract"]') + cy.get('@firstResult') + .find('[data-cy="recordAbstract"]') .should('be.visible') }) - it('should display the provider', () => { - cy.get('@dataset') - .find('div') - .filter('[data-cy="recordOrg"]') - .should('be.visible') + it('should display the organization', () => { + cy.get('@firstResult').find('[data-cy="recordOrg"]').should('be.visible') }) it('should display the star and like count', () => { - cy.get('@dataset') - .find('div') - .filter('[data-cy="recordFav"]') - .should('be.visible') + cy.get('@firstResult').find('[data-cy="recordFav"]').should('be.visible') }) }) - describe('list actions', () => { + describe('interactions with dataset', () => { beforeEach(() => { - cy.get('gn-ui-record-preview-row').children('a').first().as('dataset') + cy.get('@firstResult') + .find('gn-ui-favorite-star') + .eq(0) + .as('favoriteStar') }) - it('should open the dataset page on click', () => { - cy.get('@dataset').click() - cy.url().should('include', '/dataset/') + it('should open the dataset page in the same application on click', () => { + cy.get('@firstResult').click() + cy.url().should('match', /^http:\/\/localhost:[0-9]+\/dataset\/.+/) }) - it('should add the dataset to favorites on click on star', () => { - cy.get('gn-ui-favorite-star').eq(0).as('favoriteStar') - cy.get('@favoriteStar') - .find('span') - .invoke('text') - .then(($text) => { - const initialLength = $text - cy.wrap(initialLength).as('initialLength') - cy.get('@favoriteStar').trigger('mouseenter') - cy.get('[id="tippy-1"]').find('a').click() - cy.get('.form-group').invoke('css', 'display', 'block') - cy.wait(4000) - cy.url().should('include', 'signin') - cy.get('input').as('login') - cy.get('@login').eq(1).type('admin', { force: true }) - cy.get('@login').eq(2).type('admin', { force: true }) - cy.get('[name="gnSigninForm"]').find('button').realClick() - cy.url().should('include', '/search') - cy.get('@favoriteStar').find('span').invoke('text').as('initialCount') - cy.get('@favoriteStar') - .find('gn-ui-star-toggle') - .find('button') - .click() - cy.get('@favoriteStar') - .find('span') - .invoke('text') - .should('not.eq', '@initialLength') - }) + describe('not logged in', () => { + it('should show a popover with login link when hovering the favorite star', () => { + cy.get('@favoriteStar').trigger('mouseenter') + cy.get('[id="tippy-1"]') + .find('a') + .invoke('attr', 'href') + .should('include', 'catalog.signin') + }) + }) + describe('when logged in', () => { + beforeEach(() => { + cy.login() + cy.visit('/search') + }) + it('should toggle the dataset favorite star after a click', () => { + cy.get('@favoriteStar').find('span').invoke('text').as('initialCount') + cy.get('@favoriteStar').click() + cy.get('@favoriteStar') + .find('span') + .invoke('text') + .then((text) => { + cy.get('@initialCount').should('not.eq', text) + }) + }) }) }) - describe('list actions', () => { + describe('filtering options', () => { + const getFilterOptions = () => { + cy.get('[id^=dropdown-multiselect-] label').as('options') + cy.get('@options') + .then((options) => + options.toArray().map((element) => element.innerText.trim()) + ) + .as('optionsLabel') + cy.get('@options') + .then((options) => + options.toArray().map((element) => + element.innerText + .trim() + .replace(/\(\d+\)$/, '') + .trim() + ) + ) + .as('optionsLabelWithoutCount') + } + + const checkHasDuplicates = (options: string[]) => { + const hasDuplicates = options.some( + (text, index) => options.indexOf(text) !== index + ) + expect(hasDuplicates).to.be.false + } + beforeEach(() => { + // expand filters cy.get('datahub-search-filters') - .find('gn-ui-filter-dropdown') - .as('filters') - }) - it('should display all filters on click on button', () => { - cy.get('datahub-search-filters') - .children('div') - .children('div') - .eq(1) - .find('gn-ui-button') + .find('[data-cy=filters-expand]') + .find('button') .click() + }) + it('should display all filters', () => { cy.get('@filters').filter(':visible').should('have.length', 6) + cy.get('@filters') + .children() + .then(($dropdowns) => + $dropdowns + .toArray() + .map((dropdown) => dropdown.getAttribute('data-cy-field')) + ) + .should('eql', [ + 'publisher', + 'format', + 'publicationYear', + 'topic', + 'isSpatial', + 'license', + ]) }) - describe('have the right options in filters', () => { + + describe('publisher filter', () => { beforeEach(() => { - cy.get('datahub-search-filters') - .children('div') - .children('div') - .eq(1) - .find('gn-ui-button') - .click() - cy.get('[data-cy="addMoreBtn"]').trigger('click') - cy.get('@filters').first().click() + cy.get('@filters').eq(0).click() + getFilterOptions() + }) + it('should have options', () => { + cy.get('@options').should('have.length.above', 0) }) - it('should not have duplicates', () => { - let dropdownOptions = [] - cy.get('[id^=dropdown-multiselect-]').then((dropdown) => { - const options = dropdown.find('label') - const regex = /\(\d+\)/g - dropdownOptions = options - .map((index, element) => - Cypress.$(element).text().replace(regex, '').trim() - ) - .get() - const hasDuplicates = dropdownOptions.some( - (text, index) => dropdownOptions.indexOf(text) !== index - ) - expect(hasDuplicates).to.be.false - }) + cy.get('@optionsLabelWithoutCount').then(checkHasDuplicates) }) - it('should contain all organizations', () => { - cy.get('[id^=dropdown-multiselect-]').then((dropdown) => { - const options = dropdown.find('label') - const regex = /\(\d+\)/g - const dropdownOptions = options - .map((index, element) => - Cypress.$(element).text().replace(regex, '').trim() - ) - .get() - cy.get('[data-cy="recordOrg"]') - .invoke('text') - .then((value) => { - const listOptions = value.split(' ').map((item) => item.trim()) - const uniqueListOptions = [...new Set(listOptions)] - - uniqueListOptions.forEach((item) => { - expect(dropdownOptions).to.include(item) - }) + describe('filter by one option', () => { + beforeEach(() => { + cy.get('@options').eq(11).click() + cy.get('@resultsCount').then((resultsCount) => { + cy.get('@results').should('have.length.below', resultsCount) // wait for results change + }) + cy.get('@options') + .eq(11) + .then((option) => { + const optionText = option.text().trim() + const matches = /^(.*) \((\d+)\)$/.exec(optionText) + const orgName = matches[1] + const resultCount = parseInt(matches[2]) + return [orgName, resultCount] }) + .as('nameAndCount') }) - }) - it('should have an accurate count of data per org', () => { - const dropdownOptions = [] - cy.get('[id^=dropdown-multiselect-]') + it('should filter by owner org and give the correct results count', () => { + cy.get<[string, number]>('@nameAndCount').then( + ([orgName, resultsCount]) => { + cy.get('@results') + .find('[data-cy="recordOrg"]') + .then((orgs) => { + const orgNames = orgs + .toArray() + .map((org) => org.innerText.trim()) + expect(orgNames).to.eql( + new Array(resultsCount).fill(orgName.toUpperCase()) + ) + }) + } + ) + }) - .find('label') - .each((element) => { - dropdownOptions.push(Cypress.$(element).text().trim()) + it('shows all results if another click on option', () => { + cy.get('@options').eq(11).click() + cy.get('@resultsCount').then((resultsCount) => { + cy.get('@results').should('have.length', resultsCount) }) - cy.then(() => { - expect(dropdownOptions).to.eql([ - 'Agence wallonne du Patrimoine (SPW - Territoire, Logement, Patrimoine, Énergie - Agence wallonne du Patrimoine) (1)', - 'atmo Hauts-de-France (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)', - "Direction de l'Action sociale (SPW - Intérieur et Action sociale - Département de l'Action sociale - Direction de l'Action sociale) (1)", - 'DREAL (1)', - "DREAL HdF (Direction Régionale de l'Environnement de l'Aménagement et du Logement des Hauts de France) (1)", - '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)', - 'Service public de Wallonie (SPW) (2)', - "Société Publique de Gestion de l'Eau (SPGE) (1)", - ]) }) - }) - }) - describe('filter the list on click on options', () => { - let filterLength - beforeEach(() => { - cy.visit('/search') - cy.get('[data-cy="addMoreBtn"]').trigger('click') - cy.get('@filters') - .its('length') - .then((len) => { - filterLength = len + describe('add another option', () => { + beforeEach(() => { + cy.get('@options').eq(10).click() + }) + it('increases the result count', () => { + cy.get<[string, number]>('@nameAndCount').then( + ([, resultsCount]) => { + cy.get('@results').should('have.length.above', resultsCount) // wait for results change + } + ) }) - }) - it('first option then second option', () => { - for (let i = 0; i < filterLength; i++) { - cy.visit('/search') - cy.get('[data-cy="addMoreBtn"]').trigger('click') - cy.get('datahub-search-filters') - .children('div') - .children('div') - .eq(1) - .find('gn-ui-button') - .click() - cy.get('@filters').eq(i).click() - let expectedCount - const regex = /\(\d+\)/g - cy.get('[id^=dropdown-multiselect-]').each(($dropdown) => { - const label = $dropdown.prev('label') - if (label.text() !== '') { - cy.get('[id^=dropdown-multiselect-]') - .find('label') + describe('clearing all filters', () => { + beforeEach(() => { + cy.get('body').click() // close dropdown + cy.get('@filters') .eq(0) - .find('span') - .invoke('text') - .then((val) => { - expectedCount = Number( - val.match(regex)[0].replace('(', '').replace(')', '') - ) - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(0) - .find('input') - .click() - cy.get('gn-ui-results-list-item').should( - 'have.length', - expectedCount - ) - - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(1) - .as('provider2') - .find('span') - .invoke('text') - .then((val) => { - expectedCount = - expectedCount + - Number( - val.match(regex)[0].replace('(', '').replace(')', '') - ) - cy.get('@provider2').find('input').click() - - cy.get('gn-ui-results-list-item').should( - 'have.length', - expectedCount - ) - }) - }) - } else { - cy.log('Skipping dropdown with no label') - } + .find('[data-cy="clearSelection"]') + .click() + }) + it('shows all results again', () => { + cy.get('@resultsCount').then((resultsCount) => { + cy.get('@results').should('have.length', resultsCount) + }) + }) }) - } + }) + }) + + it('should have an accurate count of data per org', () => { + cy.get('@optionsLabel').should('eql', [ + 'Agence wallonne du Patrimoine (SPW - Territoire, Logement, Patrimoine, Énergie - Agence wallonne du Patrimoine) (1)', + 'atmo Hauts-de-France (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)', + "Direction de l'Action sociale (SPW - Intérieur et Action sociale - Département de l'Action sociale - Direction de l'Action sociale) (1)", + 'DREAL (1)', + "DREAL HdF (Direction Régionale de l'Environnement de l'Aménagement et du Logement des Hauts de France) (1)", + '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)', + 'Service public de Wallonie (SPW) (2)', + "Société Publique de Gestion de l'Eau (SPGE) (1)", + ]) }) }) - describe('filter the list upon removal of options', () => { - let filterLength + + describe('format filter', () => { beforeEach(() => { - cy.get('@filters') - .its('length') - .then((len) => { - filterLength = len - }) + cy.get('@filters').eq(1).click() + getFilterOptions() }) - it('from option list', () => { - for (let i = 0; i < filterLength; i++) { - cy.visit('/search') - cy.get('[data-cy="addMoreBtn"]').trigger('click') - cy.get('datahub-search-filters') - .children('div') - .children('div') - .eq(1) - .find('gn-ui-button') - .click() - cy.get('@filters').eq(i).click() - cy.get('[id^=dropdown-multiselect-]').each(($dropdown) => { - const label = $dropdown.prev('label') - - if (label.text() !== '') { - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(0) - .find('input') - .as('opt1') - .click() - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(1) - .find('input') - .as('opt2') - .click() - cy.get('gn-ui-results-list-item').then(($element) => { - const initialLength = $element.length - cy.wrap(initialLength).as('initialLength') - cy.get('@opt2').click() - cy.get('gn-ui-results-list-item').then(($element) => { - cy.get('@initialLength').then((initialLength) => { - expect($element.length).to.be.lessThan( - Number(initialLength) - ) - expect($element.length).to.be.greaterThan(0) - }) - }) - }) - } else { - cy.log('Skipping dropdown with no label') - } - }) - } + it('should have options', () => { + cy.get('@options').should('have.length.above', 0) }) - it('from selected options block', () => { - for (let i = 0; i < filterLength; i++) { - cy.visit('/search') - cy.get('[data-cy="addMoreBtn"]').trigger('click') - cy.get('datahub-search-filters') - .children('div') - .children('div') - .eq(1) - .find('gn-ui-button') - .click() - cy.get('@filters').eq(i).click() - cy.get('[id^=dropdown-multiselect-]').each(($dropdown) => { - const label = $dropdown.prev('label') + it('should not have duplicates', () => { + cy.get('@optionsLabelWithoutCount').then(checkHasDuplicates) + }) + }) - if (label.text() !== '') { - cy.get('gn-ui-results-list-item').then(($element) => { - const initialLength = $element.length - cy.wrap(initialLength).as('initialLength') - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(0) - .find('input') - .click() - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(1) - .find('input') - .click() - cy.get('[id^=dropdown-multiselect-]') - .children('div') - .first() - .find('button') - .first() - .click() - cy.get('gn-ui-results-list-item').then(($element) => { - cy.get('@initialLength').then((initialLength) => { - expect($element.length).to.be.lessThan( - Number(initialLength) - ) - expect($element.length).to.be.greaterThan(0) - }) - }) - }) - } else { - cy.log('Skipping dropdown with no label') - } - }) - } + describe('createDate filter', () => { + beforeEach(() => { + cy.get('@filters').eq(2).click() + getFilterOptions() }) + it('should have options', () => { + cy.get('@options').should('have.length.above', 0) + }) + it('should not have duplicates', () => { + cy.get('@optionsLabelWithoutCount').then(checkHasDuplicates) + }) + }) - it('from filter clear button', () => { - for (let i = 0; i < filterLength; i++) { - cy.visit('/search') - cy.get('[data-cy="addMoreBtn"]').trigger('click') - cy.get('datahub-search-filters') - .children('div') - .children('div') - .eq(1) - .find('gn-ui-button') - .click() - cy.get('@filters').eq(i).click() - cy.get('[id^=dropdown-multiselect-]').each(($dropdown) => { - const label = $dropdown.prev('label') + describe('theme filter', () => { + beforeEach(() => { + cy.get('@filters').eq(3).click() + getFilterOptions() + }) + it('should have options', () => { + cy.get('@options').should('have.length.above', 0) + }) + it('should not have duplicates', () => { + cy.get('@optionsLabelWithoutCount').then(checkHasDuplicates) + }) + }) - if (label.text() !== '') { - cy.get('gn-ui-results-list-item').then(($element) => { - const initialLength = $element.length - cy.wrap(initialLength).as('initialLength') - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(0) - .find('input') - .click() - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(1) - .find('input') - .click() - cy.get('[id^=dropdown-multiselect-]') - .children('div') - .first() - .find('button') - .first() - .click() - cy.get('gn-ui-results-list-item').then(($element) => { - cy.get('@initialLength').then((initialLength) => { - expect($element.length).to.be.lessThan( - Number(initialLength) - ) - expect($element.length).to.be.greaterThan(0) - }) - }) - }) - } else { - cy.log('Skipping dropdown with no label') - } - }) - } + describe('isSpatial filter', () => { + beforeEach(() => { + cy.get('@filters').eq(4).click() + getFilterOptions() }) + it('should have options', () => { + cy.get('@options').should('have.length.above', 0) + }) + it('should not have duplicates', () => { + cy.get('@optionsLabelWithoutCount').then(checkHasDuplicates) + }) + }) - it('from cross button', () => { - cy.visit('/search') - cy.get('[data-cy="addMoreBtn"]').realClick() - cy.get('datahub-search-filters') - .children('div') - .children('div') - .eq(1) - .find('gn-ui-button') - .click() - cy.get('@filters').eq(1).click() - cy.get('gn-ui-results-list-item').then(($element) => { - const initialLength = $element.length - cy.wrap(initialLength).as('initialLength') - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(0) - .find('input') - .click() - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(1) - .find('input') - .click() - cy.get('@filters') - .eq(1) - .find('gn-ui-button') - .children('button') - .realClick() - cy.get('@filters') - .eq(1) - .find('gn-ui-button') - .children('button') - .children('button') - .realClick() - cy.get('[data-cy="addMoreBtn"]').click() - cy.get('gn-ui-results-list-item').then(($element) => { - cy.get('@initialLength').then((initialLength) => { - expect($element.length).to.equal(initialLength) - }) - }) - }) + describe('licence filter', () => { + beforeEach(() => { + cy.get('@filters').eq(5).click() + getFilterOptions() + }) + it('should have options', () => { + cy.get('@options').should('have.length.above', 0) + }) + it('should not have duplicates', () => { + cy.get('@optionsLabelWithoutCount').then(checkHasDuplicates) }) }) describe('multiple filters', () => { - let listLength beforeEach(() => { - cy.get('[data-cy="addMoreBtn"]').trigger('click') - cy.get('gn-ui-results-list-item') - .its('length') - .then((len) => { - listLength = len - }) + cy.get('datahub-search-filters').scrollIntoView() + + // filter by org + cy.get('@filters').eq(0).click() + getFilterOptions() + cy.get('@options').eq(1).click() + cy.get('body').click() + + // filter by theme + cy.get('@filters').eq(3).click() + getFilterOptions() + cy.get('@options').last().click() + cy.get('body').click() }) - it('should change on adding new filter', () => { - cy.get('@filters').eq(1).click() - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(0) - .find('input') - .click() - cy.get('gn-ui-results-list-item').then(($element) => { - const newLength = $element.length - cy.wrap(newLength).as('newLength') - cy.get('@newLength').should('be.lessThan', Number(listLength)) - cy.get('@filters').eq(1).realClick() - cy.get('@filters').eq(0).realClick() - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(2) - .find('input') - .click() - cy.get('gn-ui-results-list-item').then(($element) => { - cy.get('@newLength').then((newLength) => { - expect($element.length).to.be.lessThan(Number(newLength)) - }) - }) + it('shows only one result', () => { + cy.get('@resultsCount').then((resultsCount) => { + cy.get('@results').should('have.length.below', resultsCount) }) }) - it('should change the list once on removing one filter', () => { - cy.get('@filters').eq(1).click() - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(0) - .find('input') - .click() - cy.get('@filters').eq(1).realClick() - cy.get('@filters').eq(0).realClick() - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(2) - .find('input') - .click() - cy.get('gn-ui-results-list-item').then(($element) => { - const newLength = $element.length - cy.wrap(newLength).as('newLength') - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(2) - .find('input') - .click() - cy.get('gn-ui-results-list-item').then(($element) => { - cy.get('@newLength').then((newLength) => { - expect($element.length).to.be.greaterThan(Number(newLength)) - }) + + describe('clearing all filters', () => { + beforeEach(() => { + cy.get('[data-cy="clearFilters"]').click() + }) + it('shows all results again', () => { + cy.get('@resultsCount').then((resultsCount) => { + cy.get('@results').should('have.length', resultsCount) }) }) }) }) + }) - describe('sort and clear filters', () => { - let listLength + describe('sorting results', () => { + describe('sort by popularity', () => { beforeEach(() => { - cy.get('[data-cy="addMoreBtn"]').trigger('click') - cy.get('gn-ui-results-list-item') - .its('length') - .then((len) => { - listLength = len - }) - cy.get('datahub-search-filters') - .children('div') - .children('div') - .eq(1) - .find('gn-ui-button') - .click() - }) - it('should clear all applied filters on click', () => { - cy.get('@filters').eq(0).click() - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(0) - .find('input') - .click({ force: true }) - cy.get('@filters').eq(0).realClick() - cy.get('@filters').eq(1).realClick() - cy.get('[id^=dropdown-multiselect-]') - .find('label') - .eq(0) - .find('input') - .realClick() - cy.get('gn-ui-results-list-item').should( - 'have.length.lessThan', - listLength - ) - cy.get('@filters').eq(1).realClick() - cy.get('[data-cy="clearFilters"]').realClick() - cy.get('gn-ui-results-list-item').then(($element) => { - expect($element.length).to.equal(Number(listLength)) - }) + cy.get('@sortBy').find('select').select('desc,userSavedCount') + cy.get('@results') + .find('gn-ui-favorite-star') + .find('span') + .then(($counts) => + $counts.toArray().map((span) => parseInt(span.innerText.trim())) + ) + .as('favoriteCount') }) it('should sort the list by popularity', () => { - cy.get('gn-ui-results-list-item').as('initialList') - cy.get('datahub-search-filters') - .find('gn-ui-dropdown-selector') - .find('select') - .select(2) - cy.get('gn-ui-results-list-item').should('not.eq', '@initialList') - cy.get('gn-ui-favorite-star') - .find('span') - .invoke('text') - .then(($element) => { - let outputOrder = false - for (let i = 0; i < $element.length - 1; i++) { - if (parseInt($element[i]) < parseInt($element[i + 1])) { - outputOrder = false - } else { - outputOrder = true - } - expect(outputOrder).to.be.true - } - }) + cy.get('@favoriteCount').then((favoritesCount) => { + const ordered = favoritesCount.sort((a, b) => b - a) + expect(favoritesCount).to.eql(ordered) + }) }) - it('should sort the list by date', () => { - cy.get('gn-ui-results-list-item').as('initialList') - cy.get('datahub-search-filters') - .find('gn-ui-dropdown-selector') - .find('select') - .select(1) - cy.get('gn-ui-results-list-item').should('not.eq', '@initialList') + }) + describe('sort by date', () => { + beforeEach(() => { + cy.get('@results') + .find('[data-cy="recordTitle"]') + .then(($titles) => + $titles.toArray().map((title) => title.innerText.trim()) + ) + .as('initialResultTitles') + cy.get('@sortBy').find('select').select('desc,createDate') }) - it('should sort the list by relevance', () => { - cy.get('datahub-search-filters') - .find('gn-ui-dropdown-selector') - .find('select') - .select(1) - cy.get('gn-ui-results-list-item').as('initialList') - cy.get('datahub-search-filters') - .find('gn-ui-dropdown-selector') - .find('select') - .select(0) - cy.get('gn-ui-results-list-item').should('not.eq', '@initialList') + it('changes the results order', () => { + cy.get('@initialResultTitles').then((initialResultTitles) => { + cy.get('@results') + .find('[data-cy="recordTitle"]') + .then(($titles) => + $titles.toArray().map((title) => title.innerText.trim()) + ) + .should('not.eql', initialResultTitles) + }) }) }) }) diff --git a/apps/datahub-e2e/src/support/app.po.ts b/apps/datahub-e2e/src/support/app.po.ts deleted file mode 100644 index 00f556e103..0000000000 --- a/apps/datahub-e2e/src/support/app.po.ts +++ /dev/null @@ -1 +0,0 @@ -export const getGreeting = () => cy.get('h1') diff --git a/apps/datahub-e2e/src/support/commands.ts b/apps/datahub-e2e/src/support/commands.ts index 270f023ff5..dafc568616 100644 --- a/apps/datahub-e2e/src/support/commands.ts +++ b/apps/datahub-e2e/src/support/commands.ts @@ -12,22 +12,34 @@ declare namespace Cypress { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface Chainable { - login(email: string, password: string): void + login(): void } } -// -// -- This is a parent command -- -Cypress.Commands.add('login', (email, password) => { - console.log('Custom command example: Login', email, password) + +Cypress.Commands.add('login', () => { + // ignore error coming from GN + Cypress.on('uncaught:exception', (err) => { + if (err.message.includes('Jsonix')) return false + if (err.message.includes('postMessage')) return false + }) + + cy.visit('/geonetwork/srv/eng/catalog.signin?debug') + cy.get('#inputUsername').type('admin', { force: true }) + cy.get('#inputPassword').type('admin', { force: true }) + cy.get('[name="gnSigninForm"]').submit() }) + +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// // // -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) // // // -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) // // // -- This will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 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 89e8e5f096..4a52f47ae2 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 @@ -79,6 +79,7 @@

(buttonClick)="open()" type="outline" extraClass="!p-[8px]" + data-cy="filters-expand" > more_horiz @@ -87,6 +88,7 @@

*ngIf="!isOpen" class="text-white opacity-60 hover:opacity-90 border-0 bg-transparent sm:hidden" (click)="open()" + data-cy="filters-expand" > search.filters.maximize add @@ -101,6 +103,7 @@

*ngIf="isOpen" class="text-white opacity-60 hover:opacity-90 border-0 bg-transparent hidden sm:inline" (click)="close()" + data-cy="filters-collapse" > search.filters.minimize remove diff --git a/docs/guide/deploy.md b/docs/guide/deploy.md index 09ab2c6bfd..54b1f9589b 100644 --- a/docs/guide/deploy.md +++ b/docs/guide/deploy.md @@ -2,23 +2,44 @@ outline: deep --- -# Deploy +# Deployment -After building your app, you can deploy it in any HTTP server (e.g Nginx, Apache, Azure Static Website with Blob storage ...). +This guide will offer you indications and advices for successfully deploying one or several GeoNetwork-UI applications +in your infrastructure. -Move the content of `dist/` folder into your server and adjust your configuration file (if needed). +## Basic principle + +Applications can be built using the following command: + +```shell +$ npx nx build +# is e.g. datahub or datafeeder +``` + +All build artifacts for this application will end up in the `dist/` folder. +These artifacts can be deployed in any HTTP server (e.g. Nginx, Apache, Azure Static Website with Blob storage...). + +Simply move the content of `dist/` folder into the appropriate place for your server and adjust your configuration file (if needed). ## Web Server -Geonetwork-UI apps are using path based routing strategy. HTTP server needs some modifications to make application work +Geonetwork-UI applications are using **path-based routing strategy**. This means than an application deployed on `https://my.host.org/apps/` can handle routes such as: + +- `/apps//records/all` +- `/apps//settings` +- `/apps//search?q=road` + +All these routes should in reality end up pointing to `/apps//index.html`, the rest of the path being interpreted by Angular. + +This requires the relevant HTTP server to have a specific configuration for this to work (otherwise 404 errors will happen very often). -If resource is not available, the request must be redirected to angular's `index.html`. +The configuration must essentially let the HTTP server know that if a required resource is not available, the request must be redirected to the application `index.html` file. ### NGINX -For Nginx, edit your server configuration to redirect to index.html as fallback. +For Nginx, edit your server configuration to redirect to the application `index.html` as fallback. -```bash +```text server{ listen 80; listen [::] 80; @@ -33,14 +54,14 @@ server{ ### Apache -For Apache, you will need to activate the rewrite module : +For Apache, you first need to activate the rewrite module : ```bash a2enmod rewrite systemctl restart apache2 ``` -Then there's two solutions. You will need to add thoses lines in a `.htaccess` file along the `index.html` or in a directory rule inside your `httpd.conf` +Then there are two options available. You can either add the following lines in an `.htaccess` file alongside the application `index.html` file, or in a directory rule inside your `httpd.conf`: ```bash RewriteEngine On @@ -51,3 +72,72 @@ RewriteRule ^ {link_to_angular}/index.html ``` Replace `{link_to_angular}/index.html` with your needs. + +## Authentication + +GeoNetwork-UI applications rely on the GeoNetwork authentication mechanism. This means that if the user is authenticated in GeoNetwork, they will have access to authenticated features in the corresponding GeoNetwork-UI apps. + +There are a few caveats, depending on the deployment scenario: + +::: details :relieved: GeoNetwork and GeoNetwork-UI are deployed on the same host + +> e.g. https://my.host/geonetwork and https://my.host/datahub + +In this scenario, requests from the GeoNetwork-UI app to GeoNetwork are _not_ [cross-origin requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#what_requests_use_cors), so CORS rules do not apply. + +GeoNetwork has an XSRF protection by default, which _will_ make authenticated requests fail unless the following is done: + +- either make sure that the XSRF cookies sent by GeoNetwork have a `path` value of `/`; this is typically done like so in GeoNetwork: + + ```diff + --- a/web/src/main/webapp/WEB-INF/config-security/config-security-core.xml + +++ b/web/src/main/webapp/WEB-INF/config-security/config-security-core.xml + @@ -361,6 +361,7 @@ + + + + + + ``` + + Also make sure that the GeoNetwork API URL used by the application is _not_ an absolute URL; a relative URL should be enough in that scenario: + + ```diff + --- a/conf/default.toml + +++ b/conf/default.toml + @@ -5,7 +5,7 @@ + [global] + -geonetwork4_api_url = "https://my.host/geonetwork/srv/api" + +geonetwork4_api_url = "/geonetwork/srv/api" + ``` + +- or disable the XSRF protection selectively for non-critical endpoints of GeoNetwork, e.g. https://my.host/geonetwork/srv/api/userSelections for marking records as favorites; this is typically done like so in GeoNetwork: + + ```diff + --- a/web/src/main/webapp/WEB-INF/config-security/config-security-core.xml + +++ b/web/src/main/webapp/WEB-INF/config-security/config-security-core.xml + @@ -374,6 +374,9 @@ + /[a-zA-Z0-9_\-]+/[a-z]{2,3}/csw!?.* + /[a-zA-Z0-9_\-]+/api/search/.* + /[a-zA-Z0-9_\-]+/api/site + + /[a-zA-Z0-9_\-]+/api/userselections.* + + + + ``` + + ::: warning + Please do this responsibly as this could have security implications! + ::: + +::: details :sweat: GeoNetwork and GeoNetwork-UI are not deployed on the same host + +> e.g. https://my.host/geonetwork and https://another.org/datahub + +In this scenario, even if CORS settings are correctly set up on GeoNetwork side, most authenticated request will probably fail because by default they are not sent with the [`withCredentials: true`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials) option. + +As such, **authenticated requests are not yet supported in GeoNetwork-UI in the case of a cross-origin deployment**; non-authenticated requests (e.g. public search) should still work provided CORS settings were correctly set up on the GeoNetwork side (see [CORS response headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#the_http_response_headers)). + +Lastly, even if authenticated requests were cleared regarding CORS rules, it would still be needed to disable the XSRF mechanism for the endpoints that GeoNetwork-UI relies on; XSRF protections works by making the client read the content of an HTTP cookie, and that is forbidden in a cross-origin context + +::: diff --git a/docs/guide/dev-environment.md b/docs/guide/dev-environment.md index 8a854c305e..527dc8dffd 100644 --- a/docs/guide/dev-environment.md +++ b/docs/guide/dev-environment.md @@ -2,8 +2,84 @@ outline: deep --- -# Dev environment +# How to set up a development environment -## Chapter 1 +This guide will help you set up the most appropriate development environment for your needs and objectives. -## Chapter 2 +## Overview + +A typical development environment on GeoNetwork-UI looks like this: + +- All **backend services** (GeoNetwork, ElasticSearch, database...) are run using the `support-services` docker composition +- The **GeoNetwork-UI application** in development is run using `nx serve` + +## Backend services + +The easiest way to have backend services running is to head to the [support-services](https://github.com/geonetwork/geonetwork-ui/tree/main/support-services) folder and +run + +```shell +$ docker compose up -d +``` + +to have all the required support services running locally (such as GeoNetwork). + +Alternatively, you can also adjust the GeoNetwork instance used as a backend in the [proxy-config.js](https://github.com/geonetwork/geonetwork-ui/blob/main/proxy-config.js) file like so: + +```diff +@@ -1,6 +1,6 @@ + module.exports = { + '/geonetwork': { +- target: 'http://localhost:8080', ++ target: 'https://my.catalogue.org', + secure: true, +``` + +### Specifying a different GeoNetwork version + +By default, the version of GeoNetwork used as a backend is 4.2.2. You can specify another version like so: + +```shell +$ GEONETWORK_VERSION=4.2.5 docker compose up -d +``` + +## GeoNetwork-UI code + +### Applications + +When working on a GeoNetwork-UI application, you can start it in development mode by running: + +```shell +$ npx nx serve +# is e.g. datahub or datafeeder +``` + +The application is then available at http://localhost:4200. + +Any changes to the code will be recompiled immediately and the browser will refresh automatically. + +### Smart components & services + +When working on smart components & services (usually sitting in `feature` libs), it might not be necessary to +start the whole stack of backend services and GeoNetwork-UI application from the start. + +You can most likely simply **iterate over unit tests** to achieve the desired result. Starting the application can be done at a later stage for verification +purposes. + +### Presentation components + +Presentation components are typically very encapsulated, and mainly rely on inputs without any complex dependencies. + +As such, the quickest and easiest way to develop presentation components is often simply to rely on [Storybook](https://storybook.js.org) which offers: + +- Automatic hot reloading of the component +- Many options for adjusting inputs and controlling outputs +- Isolated rendering of the component + +To start Storybook, run: + +```shell +$ npm run storybook +``` + +For a guide on how to write Angular component stories, see: https://storybook.js.org/docs/angular/writing-stories/introduction diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 91b856d49e..f753e53ee6 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -4,6 +4,32 @@ outline: deep # Getting started -## Chapter 1 +## Requirements -## Chapter 2 +- ElasticSearch version **7.11+** +- GeoNetwork version **4.2.2 and above** + > Note: the **4.4.x** versions are currently not supported! + +::: info + +A bug in GeoNetwork 4.2.2 prevents the organizations from showing up correctly in the DataHub application. + +As a temporary workaround, the following change is necessary in GeoNetwork data directory: + +```diff +diff --git a/web/src/main/webResources/WEB-INF/data/config/index/records.json b/web/src/main/webResources/WEB-INF/data/config/index/records.json +index 1d7e499af7..78e682e3db 100644 +--- a/web/src/main/webResources/WEB-INF/data/config/index/records.json ++++ b/web/src/main/webResources/WEB-INF/data/config/index/records.json +@@ -1317,7 +1317,7 @@ + "mapping": { + "type": "nested", + "properties": { +- "org": { ++ "organisation": { + "type": "keyword" + }, + "role": { +``` + +::: diff --git a/docs/guide/webcomponents.md b/docs/guide/webcomponents.md index 20a241292a..373d834185 100644 --- a/docs/guide/webcomponents.md +++ b/docs/guide/webcomponents.md @@ -61,7 +61,7 @@ e.g: http://localhost:8001/webcomponents/gn-results-list.sample.html ## Create a new Web Component -The architecture is designed so you can export an Angular component to a custom element (eg Web Component), +The architecture is designed so that you can export an Angular component to a custom element (e.g. Web Component), that is encapsulated with its style in a shadow DOM element, and can be embedded in any website. To export content as a Web Component you have to: @@ -89,7 +89,7 @@ const CUSTOM_ELEMENTS: any[] = [ ``` - Add stories for storybook to run it (angular and element stories) -- Add a sample HTML file to show how to use it in a third party web page `${webcomponent_name}.sample.html` eg. gn-results-list.sample.html +- Add a sample HTML file to show how to use it in a third party web page `${webcomponent_name}.sample.html` e.g. gn-results-list.sample.html ## Update Web Component inputs diff --git a/docs/guide/why.md b/docs/guide/why.md index 0f5f9b04bb..c5c85870ec 100644 --- a/docs/guide/why.md +++ b/docs/guide/why.md @@ -2,15 +2,13 @@ outline: deep --- -# Why geonetwork-ui ? +# Why GeoNetwork-UI ? -L’ouverture et la démocratisation du partage et de la réutilisation des données publiques ont transformé le paysage du catalogage de données en France et en Europe. +The GeoNetwork-UI project has been conceived as a way to depart from the long-standing GeoNetwork web-ui application based on AngularJS, +and offer new functionalities and better user experience on top of the existing GeoNetwork API. Its core functionalities are +a **powerful search engine**, various **data visualization** components, and a better support for **non-geographic and open data** resources. -Une solution de catalogage moderne doit +Read the [Vision](./vision.html) section to understand better which approach is being adopted for this project and why. -- Unifier les notions de Opendata et de données géographiques qui représentent 2 écosystèmes trop disjoints. -- Proposer des usages modernes et innovants pour la valorisation des données publiées. -- Fournir un moteur de recherche performant et redimensionnable, qui trouve l’information à la fois dans les métadonnées et les données. -- Valoriser la réutilisation des données via des API de recherche, analyse et exploration. - -Geonetwork-ui répond à ces besoins en mettant l’accent sur **l’expérience utilisateur**. +GeoNetwork-UI offers different applications suited to different use-cases. Applications are documented +in the [corresponding section](../apps/datahub.html). diff --git a/docs/index.md b/docs/index.md index d8b80d55bd..46fd503520 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,7 +26,7 @@ features: details: Make your dataset public and available. - icon: 🔎 title: Explore - details: Explore your data through a powerfull search engine. Tables, maps and dataviz charts. + details: Explore your data through a powerful search engine. Tables, maps and dataviz charts. - icon: 🤝 title: Share details: Share your work. diff --git a/docs/reference/elasticsearch.md b/docs/reference/elasticsearch.md index ea2f9d63ef..12f9cb1790 100644 --- a/docs/reference/elasticsearch.md +++ b/docs/reference/elasticsearch.md @@ -2,7 +2,7 @@ outline: deep --- -# Elastisearch Index +# Elasticsearch Index ## Chapter 1 diff --git a/docs/reference/i18n.md b/docs/reference/i18n.md index 4eac64ebee..ec3f24311d 100644 --- a/docs/reference/i18n.md +++ b/docs/reference/i18n.md @@ -17,7 +17,7 @@ Languages in GeoNetwork-UI should always be identified by their [two-character c ## Supported languages -Currently a small amount of languages are supported, see: https://github.com/geonetwork/geonetwork-ui/blob/main/libs/util/i18n/src/lib/i18n.constants.ts +Currently, a small amount of languages is supported, see: https://github.com/geonetwork/geonetwork-ui/blob/main/libs/util/i18n/src/lib/i18n.constants.ts ## Where translations are stored @@ -35,7 +35,7 @@ The rules for showing the translated labels on screen are: - avoid using instant translation in the code: in case the language is switched dynamically, labels translated that way will not be updated - if translation keys are computed dynamically, use the [`marker()`](https://github.com/biesbjerg/ngx-translate-extract-marker) function to declare them beforehand; **translation keys should be discoverable statically by analyzing the source code!** -When a contribution adds new translated labels, the `npm run i18n:extract` command (which relies on the [`ngx-translate-extract`](https://github.com/biesbjerg/ngx-translate-extract) library) should be run and its results committed seperately. English labels should always be provided for new keys as this is the fallback language. +When a contribution adds new translated labels, the `npm run i18n:extract` command (which relies on the [`ngx-translate-extract`](https://github.com/biesbjerg/ngx-translate-extract) library) should be run and its results committed separately. English labels should always be provided for new keys as this is the fallback language. ## How to contribute new translations diff --git a/docs/reference/state-management.md b/docs/reference/state-management.md index 5fa8e77bf5..514ef64db7 100644 --- a/docs/reference/state-management.md +++ b/docs/reference/state-management.md @@ -88,7 +88,7 @@ For more information, please refer to the [official documentation](https://ngrx. You can update the state only through **Actions**, which are a combination of -- a `type`, it's a string with the following pattern `"[state_name] action_description"` (eg. `[Search] Set filters'`) +- a `type`, it's a string with the following pattern `"[state_name] action_description"` (e.g. `[Search] Set filters'`) - a `payload`, could be any input to change the state (eg: filters) ### Listen to state changes @@ -100,7 +100,7 @@ You can create your own selectors to listen to specific changes within the state To handle state change side effects, for instance for asynchronous actions, you can use **Effects**. -An effect is a subscription to an Observable (mostly to other actions) which often dispatches other actions. (eg `Load` action can dispatch `LoadSuccess` or `LoadFailure` action through effects). +An effect is a subscription to an Observable (mostly to other actions) which often dispatches other actions. (e.g. `Load` action can dispatch `LoadSuccess` or `LoadFailure` action through effects). ### Facades @@ -158,7 +158,7 @@ The actions & effects are responsible for triggering a search request to the bac As you could have several searches within the application, search state is not a singleton, there is no unique service to handle the search state. -You have to initiate one state per search you want to have (eg feeds, search, etc...) +You have to initiate one state per search you want to have (e.g. feeds, search, etc...) ### Search containers @@ -186,7 +186,7 @@ To create a search state, the best way is to use a search container directive. Y Adding such a directive in your code automatically - initializes a search state with the id `newsfeed`. -- instanciates a new `SearchFacade` object for the `newsfeed` state. +- instantiates a new `SearchFacade` object for the `newsfeed` state. - injects the dedicated `SearchService`, corresponding to the container type. - encapsulates all DOM tree underneath the directive scope. It means that every component within the container DOM, which inject the `SearchService` will get the implementation provided by the container directive. diff --git a/libs/api/metadata-converter/src/lib/gn4/atomic-operations.ts b/libs/api/metadata-converter/src/lib/gn4/atomic-operations.ts index 7c1dc4d6a8..b442f1a2f7 100644 --- a/libs/api/metadata-converter/src/lib/gn4/atomic-operations.ts +++ b/libs/api/metadata-converter/src/lib/gn4/atomic-operations.ts @@ -64,7 +64,10 @@ export const mapOrganization = ( const website = getAsUrl(selectField(sourceContact, 'website')) const logoUrl = getAsUrl(selectField(sourceContact, 'logo')) return { - name: selectField(sourceContact, 'organisation'), + name: selectFallback( + selectTranslatedField(sourceContact, 'organisationObject'), + selectField(sourceContact, 'organisation') + ), ...(logoUrl && { logoUrl }), ...(website && { website }), } 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 928e0c4511..fe26aef0db 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 @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing' import { GroupsApiService, SearchApiService, + SiteApiService, } from '@geonetwork-ui/data-access/gn4' import { firstValueFrom, lastValueFrom, of } from 'rxjs' import { take } from 'rxjs/operators' @@ -38,6 +39,8 @@ const sampleOrgC: Organization = { website: new URL('https://www.ifremer.fr/'), } +let geonetworkVersion: string + const organisationsAggregationMock = { aggregations: { contact: { @@ -141,248 +144,259 @@ class GoupsApiServiceMock { getGroups = jest.fn(() => of(GROUPS_FIXTURE)) } -describe('OrganizationsFromMetadataService', () => { - let service: OrganizationsFromMetadataService - let searchService: SearchApiService +class SiteApiServiceMock { + getSiteOrPortalDescription = jest.fn(() => + of({ + 'system/platform/version': geonetworkVersion, + }) + ) +} - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - OrganizationsFromMetadataService, - { - provide: GroupsApiService, - useClass: GoupsApiServiceMock, - }, - { - provide: SearchApiService, - useClass: SearchApiServiceMock, - }, - ], +describe.each(['4.2.2-00', '4.2.3-xx', '4.2.5-xx'])( + 'OrganizationsFromMetadataService (gn v%s)', + (gnVersion) => { + let service: OrganizationsFromMetadataService + let searchService: SearchApiService + + beforeEach(() => { + geonetworkVersion = gnVersion }) - service = TestBed.inject(OrganizationsFromMetadataService) - searchService = TestBed.inject(SearchApiService) - }) - it('should be created', () => { - expect(service).toBeTruthy() - }) - describe('organisations$', () => { - let organisations - describe('initially', () => { - beforeEach(() => { - service.organisations$ - .pipe(take(1)) - .subscribe((orgs) => (organisations = orgs)) + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OrganizationsFromMetadataService, + { + provide: GroupsApiService, + useClass: GoupsApiServiceMock, + }, + { + provide: SearchApiService, + useClass: SearchApiServiceMock, + }, + { + provide: SiteApiService, + useClass: SiteApiServiceMock, + }, + ], }) - it('call search service', () => { - expect(searchService.search).toHaveBeenCalledWith( - 'bucket', - JSON.stringify({ - aggregations: { - contact: { - nested: { path: 'contactForResource' }, - aggs: { - org: { - terms: { - field: 'contactForResource.organisation', - exclude: '', - size: 5000, - order: { _key: 'asc' }, - }, - aggs: { - mail: { - terms: { - size: 50, - exclude: '', - field: 'contactForResource.email.keyword', - }, + service = TestBed.inject(OrganizationsFromMetadataService) + searchService = TestBed.inject(SearchApiService) + }) + + it('should be created', () => { + expect(service).toBeTruthy() + }) + + let contactOrgField: string + let orgField: string + let emailField: string + + beforeEach(() => { + contactOrgField = geonetworkVersion.startsWith('4.2.2') + ? 'organisation' + : 'organisationObject.default.keyword' + orgField = geonetworkVersion.startsWith('4.2.2') + ? 'OrgForResource' + : 'OrgForResourceObject.default' + emailField = geonetworkVersion.startsWith('4.2.5') + ? 'email' + : 'email.keyword' + }) + + describe('organisations$', () => { + let organisations + describe('initially', () => { + beforeEach(() => { + service.organisations$ + .pipe(take(1)) + .subscribe((orgs) => (organisations = orgs)) + }) + it('call search service', () => { + expect(searchService.search).toHaveBeenCalledWith( + 'bucket', + JSON.stringify({ + aggregations: { + contact: { + nested: { path: 'contactForResource' }, + aggs: { + org: { + terms: { + field: `contactForResource.${contactOrgField}`, + exclude: '', + size: 5000, + order: { _key: 'asc' }, }, - logoUrl: { - terms: { - size: 1, - exclude: '', - field: 'contactForResource.logo.keyword', + aggs: { + mail: { + terms: { + size: 50, + exclude: '', + field: `contactForResource.${emailField}`, + }, + }, + logoUrl: { + terms: { + size: 1, + exclude: '', + field: `contactForResource.logo.keyword`, + }, }, }, }, }, }, - }, - orgForResource: { - terms: { - size: 5000, - exclude: '', - field: 'OrgForResource', - order: { - _key: 'asc', + orgForResource: { + terms: { + size: 5000, + exclude: '', + field: orgField, + order: { + _key: 'asc', + }, }, }, }, - }, - from: 0, - size: 0, - query: { - bool: { - must: [{ terms: { isTemplate: ['n'] } }], - must_not: { - terms: { - resourceType: [ - 'service', - 'map', - 'map/static', - 'mapDigital', - ], + from: 0, + size: 0, + query: { + bool: { + must: [{ terms: { isTemplate: ['n'] } }], + must_not: { + terms: { + resourceType: [ + 'service', + 'map', + 'map/static', + 'mapDigital', + ], + }, }, + should: [], + filter: [], }, - should: [], - filter: [], }, + _source: [], + }) + ) + }) + it('get rough organisations', () => { + expect(organisations).toEqual([ + { + emails: [ + 'rolf.giezendanner@are.admin.ch', + 'john.doe@are.admin.ch', + ], + name: 'ARE', + recordCount: 5, }, - _source: [], - }) - ) + { + emails: ['christian.meier@bakom.admin.ch'], + logoUrl: new URL( + 'https://ids.fr/geonetwork/images/harvesting/logo_min.png' + ), + name: 'BAKOM', + recordCount: 2, + }, + { + emails: ['ifremer.ifremer@ifremer.admin.ch'], + name: 'Ifremer', + recordCount: 1, + }, + ]) + }) }) - it('get rough organisations', () => { - expect(organisations).toEqual([ - { - emails: ['rolf.giezendanner@are.admin.ch', 'john.doe@are.admin.ch'], - name: 'ARE', - recordCount: 5, - }, - { - emails: ['christian.meier@bakom.admin.ch'], - logoUrl: new URL( - 'https://ids.fr/geonetwork/images/harvesting/logo_min.png' - ), - name: 'BAKOM', - recordCount: 2, - }, - { - emails: ['ifremer.ifremer@ifremer.admin.ch'], - name: 'Ifremer', - recordCount: 1, - }, - ]) + describe('when groups tick', () => { + beforeEach(() => { + organisations = null + service.organisations$ + .pipe(take(2)) + .subscribe((orgs) => (organisations = orgs)) + }) + it('get organisations hydrated from groups via name or email mapping', () => { + expect(organisations).toEqual([sampleOrgA, sampleOrgB, sampleOrgC]) + }) }) }) - describe('when groups tick', () => { - beforeEach(() => { - organisations = null - service.organisations$ - .pipe(take(2)) - .subscribe((orgs) => (organisations = orgs)) + describe('#normalizeString', () => { + it('should match "ATMO Haut de France" and "ATMO Haut-de-France"', () => { + expect(service.normalizeString('ATMO Haut de France')).toEqual( + service.normalizeString('ATMO Haut-de-France') + ) }) - it('get organisations hydrated from groups via name or email mapping', () => { - expect(organisations).toEqual([sampleOrgA, sampleOrgB, sampleOrgC]) + it('should match "ATMO Haut de France" and "ATMOHautdeFrance"', () => { + expect(service.normalizeString('ATMO Haut de France')).toEqual( + service.normalizeString('ATMOHautdeFrance') + ) }) - }) - }) - describe('#normalizeString', () => { - it('should match "ATMO Haut de France" and "ATMO Haut-de-France"', () => { - expect(service.normalizeString('ATMO Haut de France')).toEqual( - service.normalizeString('ATMO Haut-de-France') - ) - }) - it('should match "ATMO Haut de France" and "ATMOHautdeFrance"', () => { - expect(service.normalizeString('ATMO Haut de France')).toEqual( - service.normalizeString('ATMOHautdeFrance') - ) - }) - it('should NOT match "ATMO Haut de France" and "ATMO HDF"', () => { - expect(service.normalizeString('ATMO Haut de France')).not.toEqual( - service.normalizeString('ATMO HDF') - ) - }) - }) - describe('#compareNormalizedString', () => { - it('should match "ATMO Haut de France" and "ATMO Haut-de-France"', () => { - expect( - service.equalsNormalizedStrings( - 'ATMO Haut de France', - 'ATMO Haut-de-France' + it('should NOT match "ATMO Haut de France" and "ATMO HDF"', () => { + expect(service.normalizeString('ATMO Haut de France')).not.toEqual( + service.normalizeString('ATMO HDF') ) - ).toBeTruthy() + }) }) - it('should NOT match "ATMO Haut de France" and "ATMO Haut-de-France" (not replacing special chars)', () => { - expect( - service.equalsNormalizedStrings( - 'ATMO Haut de France', - 'ATMO Haut-de-France', - false - ) - ).toBeFalsy() + describe('#compareNormalizedString', () => { + it('should match "ATMO Haut de France" and "ATMO Haut-de-France"', () => { + expect( + service.equalsNormalizedStrings( + 'ATMO Haut de France', + 'ATMO Haut-de-France' + ) + ).toBeTruthy() + }) + it('should NOT match "ATMO Haut de France" and "ATMO Haut-de-France" (not replacing special chars)', () => { + expect( + service.equalsNormalizedStrings( + 'ATMO Haut de France', + 'ATMO Haut-de-France', + false + ) + ).toBeFalsy() + }) + it('should match email adresses (not replacing special chars)', () => { + expect( + service.equalsNormalizedStrings( + 'Some.user@C2C.com', + 'some.user@c2c.com', + false + ) + ).toBeTruthy() + }) }) - it('should match email adresses (not replacing special chars)', () => { - expect( - service.equalsNormalizedStrings( - 'Some.user@C2C.com', - 'some.user@c2c.com', - false + describe('#getFiltersForOrgs', () => { + let filters + beforeEach(async () => { + filters = await firstValueFrom( + service.getFiltersForOrgs([sampleOrgA, sampleOrgB, sampleOrgC]) ) - ).toBeTruthy() - }) - }) - describe('#getFiltersForOrgs', () => { - let filters - beforeEach(async () => { - filters = await firstValueFrom( - service.getFiltersForOrgs([sampleOrgA, sampleOrgB, sampleOrgC]) - ) - }) - it('generates filters', () => { - expect(filters).toEqual({ - OrgForResource: { ARE: true, BAKOM: true, Ifremer: true }, }) - }) - }) - describe('#getOrgsFromFilters', () => { - let orgs - beforeEach(async () => { - orgs = await lastValueFrom( - service.getOrgsFromFilters({ - OrgForResource: { - ARE: true, // org A - BAKOM: true, // org B - }, + it('generates filters', () => { + expect(filters).toEqual({ + [orgField]: { ARE: true, BAKOM: true, Ifremer: true }, }) - ) - }) - it('generates filters', () => { - expect(orgs).toEqual([sampleOrgA, sampleOrgB]) - }) - }) - describe('#addOrganizationToRecordFromSource', () => { - let record - beforeEach(async () => { - const source = { - ...ES_FIXTURE_FULL_RESPONSE.hits.hits[0]._source, - } - record = await lastValueFrom( - service.addOrganizationToRecordFromSource(source, { - title: 'Surval - Données par paramètre', - uniqueIdentifier: 'cf5048f6-5bbf-4e44-ba74-e6f429af51ea', - } as CatalogRecord) - ) + }) }) - it('adds an owner organization to the record (using the org of the first resource contact)', () => { - expect(record).toMatchObject({ - title: 'Surval - Données par paramètre', - uniqueIdentifier: 'cf5048f6-5bbf-4e44-ba74-e6f429af51ea', - ownerOrganization: { - logoUrl: new URL( - 'http://localhost/geonetwork/images/harvesting/ifremer.png' - ), - name: 'Ifremer', - description: - "Institut français de recherche pour l'exploitation de la mer", - }, + describe('#getOrgsFromFilters', () => { + let orgs + beforeEach(async () => { + orgs = await lastValueFrom( + service.getOrgsFromFilters({ + [orgField]: { + ARE: true, // org A + BAKOM: true, // org B + }, + }) + ) + }) + it('generates filters', () => { + expect(orgs).toEqual([sampleOrgA, sampleOrgB]) }) }) - describe('when no resource contacts', () => { + describe('#addOrganizationToRecordFromSource', () => { + let record beforeEach(async () => { const source = { ...ES_FIXTURE_FULL_RESPONSE.hits.hits[0]._source, - contactForResource: [], } record = await lastValueFrom( service.addOrganizationToRecordFromSource(source, { @@ -391,7 +405,7 @@ describe('OrganizationsFromMetadataService', () => { } as CatalogRecord) ) }) - it('uses the contacts array', () => { + it('adds an owner organization to the record (using the org of the first resource contact)', () => { expect(record).toMatchObject({ title: 'Surval - Données par paramètre', uniqueIdentifier: 'cf5048f6-5bbf-4e44-ba74-e6f429af51ea', @@ -405,6 +419,34 @@ describe('OrganizationsFromMetadataService', () => { }, }) }) + describe('when no resource contacts', () => { + beforeEach(async () => { + const source = { + ...ES_FIXTURE_FULL_RESPONSE.hits.hits[0]._source, + contactForResource: [], + } + record = await lastValueFrom( + service.addOrganizationToRecordFromSource(source, { + title: 'Surval - Données par paramètre', + uniqueIdentifier: 'cf5048f6-5bbf-4e44-ba74-e6f429af51ea', + } as CatalogRecord) + ) + }) + it('uses the contacts array', () => { + expect(record).toMatchObject({ + title: 'Surval - Données par paramètre', + uniqueIdentifier: 'cf5048f6-5bbf-4e44-ba74-e6f429af51ea', + ownerOrganization: { + logoUrl: new URL( + 'http://localhost/geonetwork/images/harvesting/ifremer.png' + ), + name: 'Ifremer', + description: + "Institut français de recherche pour l'exploitation de la mer", + }, + }) + }) + }) }) - }) -}) + } +) diff --git a/libs/api/repository/src/lib/gn4/organizations/organizations-from-metadata.service.ts b/libs/api/repository/src/lib/gn4/organizations/organizations-from-metadata.service.ts index 6dda1b8267..5e83f58062 100644 --- a/libs/api/repository/src/lib/gn4/organizations/organizations-from-metadata.service.ts +++ b/libs/api/repository/src/lib/gn4/organizations/organizations-from-metadata.service.ts @@ -3,6 +3,7 @@ import { GroupApiModel, GroupsApiService, SearchApiService, + SiteApiService, } from '@geonetwork-ui/data-access/gn4' import { FieldFilterByValues, @@ -19,9 +20,12 @@ import { getAsUrl, mapOrganization, MetadataObject, + selectFallback, selectField, + selectTranslatedField, + SourceWithUnknownProps, } from '@geonetwork-ui/api/metadata-converter' -import { combineLatest, Observable, of, takeLast } from 'rxjs' +import { combineLatest, Observable, of, switchMap, takeLast } from 'rxjs' import { filter, map, shareReplay, startWith, tap } from 'rxjs/operators' const IMAGE_URL = '/geonetwork/images/harvesting/' @@ -48,26 +52,35 @@ interface IncompleteOrganization { export class OrganizationsFromMetadataService implements OrganizationsServiceInterface { + geonetworkVersion$ = this.siteApiService.getSiteOrPortalDescription().pipe( + map((info) => info['system/platform/version']), + shareReplay(1) + ) + private groups$: Observable = this.groupsApiService .getGroups() .pipe(shareReplay()) private organisationsAggs$: Observable = - this.searchApiService - .search('bucket', JSON.stringify(this.getAggregationSearchRequest())) - .pipe( - filter((response) => !!response.aggregations.contact.org), - tap((response) => - response.aggregations.contact.org.buckets.forEach( - (r) => - (r.doc_count = - response.aggregations.orgForResource.buckets.find( - (org) => org.key === r.key - )?.doc_count || r.doc_count) - ) - ), - map((response) => response.aggregations.contact.org.buckets), - shareReplay() - ) + this.geonetworkVersion$.pipe( + switchMap((version) => + this.searchApiService.search( + 'bucket', + JSON.stringify(this.getAggregationSearchRequest(version)) + ) + ), + filter((response) => !!response.aggregations.contact.org), + tap((response) => + response.aggregations.contact.org.buckets.forEach( + (r) => + (r.doc_count = + response.aggregations.orgForResource.buckets.find( + (org) => org.key === r.key + )?.doc_count || r.doc_count) + ) + ), + map((response) => response.aggregations.contact.org.buckets), + shareReplay() + ) private organisationsWithoutGroups$: Observable = this.organisationsAggs$.pipe( map((buckets) => @@ -100,7 +113,8 @@ export class OrganizationsFromMetadataService constructor( private esService: ElasticsearchService, private searchApiService: SearchApiService, - private groupsApiService: GroupsApiService + private groupsApiService: GroupsApiService, + private siteApiService: SiteApiService ) {} equalsNormalizedStrings( @@ -129,7 +143,7 @@ export class OrganizationsFromMetadataService } } - private getAggregationSearchRequest() { + private getAggregationSearchRequest(gnVersion: string) { return this.esService.getSearchRequestBody({ contact: { nested: { @@ -138,7 +152,9 @@ export class OrganizationsFromMetadataService aggs: { org: { terms: { - field: 'contactForResource.organisation', + field: gnVersion.startsWith('4.2.2') + ? 'contactForResource.organisation' + : 'contactForResource.organisationObject.default.keyword', exclude: '', size: 5000, order: { _key: 'asc' }, @@ -148,7 +164,12 @@ export class OrganizationsFromMetadataService terms: { size: 50, exclude: '', - field: 'contactForResource.email.keyword', + field: + gnVersion.startsWith('4.2.2') || + gnVersion.startsWith('4.2.3') || + gnVersion.startsWith('4.2.4') + ? 'contactForResource.email.keyword' + : 'contactForResource.email', }, }, logoUrl: { @@ -166,7 +187,9 @@ export class OrganizationsFromMetadataService terms: { size: 5000, exclude: '', - field: 'OrgForResource', + field: gnVersion.startsWith('4.2.2') + ? 'OrgForResource' + : 'OrgForResourceObject.default', order: { _key: 'asc', }, @@ -206,25 +229,42 @@ export class OrganizationsFromMetadataService } getFiltersForOrgs(organisations: Organization[]): Observable { - return of({ - OrgForResource: organisations.reduce( - (prev, curr) => ({ ...prev, [curr.name]: true }), - {} - ), - }) + return this.geonetworkVersion$.pipe( + map((gnVersion) => { + const fieldName = gnVersion.startsWith('4.2.2') + ? 'OrgForResource' + : 'OrgForResourceObject.default' + return { + [fieldName]: organisations.reduce( + (prev, curr) => ({ ...prev, [curr.name]: true }), + {} + ), + } + }) + ) } getOrgsFromFilters(filters: FieldFilters): Observable { - if (!('OrgForResource' in filters)) return of([]) - return this.organisations$.pipe( - map((orgs: IncompleteOrganization[]) => { - const orgNames = Object.keys( - filters['OrgForResource'] as FieldFilterByValues - ) - return orgNames.map((name) => - orgs.find( - (org: Organization | IncompleteOrganization) => org.name === name - ) + return this.geonetworkVersion$.pipe( + switchMap((gnVersion) => { + const fieldName = gnVersion.startsWith('4.2.2') + ? 'OrgForResource' + : 'OrgForResourceObject.default' + + if (!(fieldName in filters)) return of([]) + + return this.organisations$.pipe( + map((orgs: IncompleteOrganization[]) => { + const orgNames = Object.keys( + filters[fieldName] as FieldFilterByValues + ) + return orgNames.map((name) => + orgs.find( + (org: Organization | IncompleteOrganization) => + org.name === name + ) + ) + }) ) }) ) @@ -234,9 +274,17 @@ export class OrganizationsFromMetadataService source: MetadataObject, record: CatalogRecord ): Observable { - const contacts = getAsArray(selectField(source, 'contact')) - const resourceContacts = getAsArray( - selectField(source, 'contactForResource') + const contacts: SourceWithUnknownProps[] = getAsArray( + selectFallback( + selectTranslatedField(source, 'contactObject'), + selectField(source, 'contact') + ) + ) + const resourceContacts: SourceWithUnknownProps[] = getAsArray( + selectFallback( + selectTranslatedField(source, 'contactForResourceObject'), + selectField(source, 'contactForResource') + ) ) const allContactOrgs = resourceContacts .concat(contacts) diff --git a/libs/feature/catalog/src/lib/feature-catalog.module.ts b/libs/feature/catalog/src/lib/feature-catalog.module.ts index 6c8ea4dbe3..86d5380601 100644 --- a/libs/feature/catalog/src/lib/feature-catalog.module.ts +++ b/libs/feature/catalog/src/lib/feature-catalog.module.ts @@ -5,6 +5,7 @@ import { ApiModule, GroupsApiService, SearchApiService, + SiteApiService, } from '@geonetwork-ui/data-access/gn4' import { CommonModule } from '@angular/common' import { SourceLabelComponent } from './source-label/source-label.component' @@ -32,7 +33,8 @@ const organizationsServiceFactory = ( esService: ElasticsearchService, searchApiService: SearchApiService, groupsApiService: GroupsApiService, - translateService: TranslateService + translateService: TranslateService, + siteApiService: SiteApiService ) => strategy === 'groups' ? new OrganizationsFromGroupsService( @@ -44,7 +46,8 @@ const organizationsServiceFactory = ( : new OrganizationsFromMetadataService( esService, searchApiService, - groupsApiService + groupsApiService, + siteApiService ) @NgModule({ @@ -73,6 +76,7 @@ const organizationsServiceFactory = ( SearchApiService, GroupsApiService, TranslateService, + SiteApiService, ], }, ], diff --git a/libs/feature/search/src/lib/constants.ts b/libs/feature/search/src/lib/constants.ts index efa93558ff..f7a1f42bec 100644 --- a/libs/feature/search/src/lib/constants.ts +++ b/libs/feature/search/src/lib/constants.ts @@ -13,16 +13,15 @@ export const FIELDS_SUMMARY: FieldName[] = [ 'logo', 'codelist_status_text', 'linkProtocol', - 'contactForResource.organisation', - 'contact.organisation', + 'contactForResource*.organisation*', + 'contact*.organisation*', 'userSavedCount', ] export const FIELDS_BRIEF: FieldName[] = [ ...FIELDS_SUMMARY, 'resourceTypeObject', - 'Org', - 'OrgForResource', + 'Org*', ] export const QUERY_FIELDS: FieldName[] = [ diff --git a/libs/feature/search/src/lib/filter-dropdown/filter-dropdown.component.html b/libs/feature/search/src/lib/filter-dropdown/filter-dropdown.component.html index 5b531f82ed..9ff8ff4c1b 100644 --- a/libs/feature/search/src/lib/filter-dropdown/filter-dropdown.component.html +++ b/libs/feature/search/src/lib/filter-dropdown/filter-dropdown.component.html @@ -6,5 +6,6 @@ [selected]="selected$ | async" [allowSearch]="true" (selectValues)="onSelectedValues($event)" + [attr.data-cy-field]="fieldName" > diff --git a/libs/ui/elements/src/lib/thumbnail/thumbnail.component.html b/libs/ui/elements/src/lib/thumbnail/thumbnail.component.html index 58a5e84fb5..2338383ece 100644 --- a/libs/ui/elements/src/lib/thumbnail/thumbnail.component.html +++ b/libs/ui/elements/src/lib/thumbnail/thumbnail.component.html @@ -4,6 +4,7 @@ [ngClass]="{ 'bg-white': !isPlaceholder }" + [attr.data-cy-is-placeholder]="isPlaceholder.toString()" > -