From 31d52c26843d535e20a7ec06df563a425497b6cc Mon Sep 17 00:00:00 2001
From: jnizet <jb@ninja-squad.com>
Date: Thu, 26 Aug 2021 15:34:25 +0200
Subject: [PATCH 1/2] feat: germplasm card

---
 .../web/germplasm/GermplasmController.java    | 392 ++++++++++++++++
 .../faidare/web/germplasm/GermplasmModel.java | 139 ++++++
 .../urgi/faidare/web/site/SiteController.java |   2 +-
 .../faidare/web/study/StudyController.java    |   2 +-
 .../faidare/web/thymeleaf/FaidareDialect.java |  25 ++
 .../thymeleaf/FaidareExpressionFactory.java   |  33 ++
 .../web/thymeleaf/FaidareExpressions.java     |  65 +++
 .../main/resources/static/assets/style.css    |   4 +
 .../templates/fragments/institute.html        |  34 ++
 .../resources/templates/fragments/link.html   |  20 +
 .../resources/templates/fragments/row.html    |  31 +-
 .../resources/templates/fragments/source.html |  35 ++
 .../resources/templates/fragments/xrefs.html  |   5 +
 .../main/resources/templates/germplasm.html   | 418 ++++++++++++++++++
 .../main/resources/templates/layout/main.html |  22 +
 .../src/main/resources/templates/site.html    |  17 +-
 .../src/main/resources/templates/study.html   |  16 +-
 17 files changed, 1226 insertions(+), 34 deletions(-)
 create mode 100644 backend/src/main/java/fr/inra/urgi/faidare/web/germplasm/GermplasmController.java
 create mode 100644 backend/src/main/java/fr/inra/urgi/faidare/web/germplasm/GermplasmModel.java
 create mode 100644 backend/src/main/java/fr/inra/urgi/faidare/web/thymeleaf/FaidareDialect.java
 create mode 100644 backend/src/main/java/fr/inra/urgi/faidare/web/thymeleaf/FaidareExpressionFactory.java
 create mode 100644 backend/src/main/java/fr/inra/urgi/faidare/web/thymeleaf/FaidareExpressions.java
 create mode 100644 backend/src/main/resources/templates/fragments/institute.html
 create mode 100644 backend/src/main/resources/templates/fragments/link.html
 create mode 100644 backend/src/main/resources/templates/fragments/source.html
 create mode 100644 backend/src/main/resources/templates/germplasm.html

diff --git a/backend/src/main/java/fr/inra/urgi/faidare/web/germplasm/GermplasmController.java b/backend/src/main/java/fr/inra/urgi/faidare/web/germplasm/GermplasmController.java
new file mode 100644
index 00000000..cf343049
--- /dev/null
+++ b/backend/src/main/java/fr/inra/urgi/faidare/web/germplasm/GermplasmController.java
@@ -0,0 +1,392 @@
+package fr.inra.urgi.faidare.web.germplasm;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import fr.inra.urgi.faidare.api.NotFoundException;
+import fr.inra.urgi.faidare.config.FaidareProperties;
+import fr.inra.urgi.faidare.domain.brapi.v1.data.BrapiGermplasmAttributeValue;
+import fr.inra.urgi.faidare.domain.brapi.v1.data.BrapiSibling;
+import fr.inra.urgi.faidare.domain.criteria.GermplasmAttributeCriteria;
+import fr.inra.urgi.faidare.domain.criteria.GermplasmGETSearchCriteria;
+import fr.inra.urgi.faidare.domain.data.germplasm.CollPopVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.DonorVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.GenealogyVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.GermplasmAttributeValueVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.GermplasmInstituteVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.GermplasmVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.InstituteVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.PedigreeVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.PhotoVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.PuiNameValueVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.SiblingVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.SimpleVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.SiteVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.TaxonSourceVO;
+import fr.inra.urgi.faidare.domain.xref.XRefDocumentVO;
+import fr.inra.urgi.faidare.repository.es.GermplasmAttributeRepository;
+import fr.inra.urgi.faidare.repository.es.GermplasmRepository;
+import fr.inra.urgi.faidare.repository.es.XRefDocumentRepository;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.servlet.ModelAndView;
+
+/**
+ * Controller used to display a germplasm card based on its ID.
+ * @author JB Nizet
+ */
+@Controller("webGermplasmController")
+@RequestMapping("/germplasms")
+public class GermplasmController {
+
+    private final GermplasmRepository germplasmRepository;
+    private final FaidareProperties faidareProperties;
+    private final XRefDocumentRepository xRefDocumentRepository;
+    private GermplasmAttributeRepository germplasmAttributeRepository;
+
+    public GermplasmController(GermplasmRepository germplasmRepository,
+                               FaidareProperties faidareProperties,
+                               XRefDocumentRepository xRefDocumentRepository,
+                               GermplasmAttributeRepository germplasmAttributeRepository) {
+        this.germplasmRepository = germplasmRepository;
+        this.faidareProperties = faidareProperties;
+        this.xRefDocumentRepository = xRefDocumentRepository;
+        this.germplasmAttributeRepository = germplasmAttributeRepository;
+    }
+
+    @GetMapping("/{germplasmId}")
+    public ModelAndView get(@PathVariable("germplasmId") String germplasmId) {
+        // GermplasmVO germplasm = germplasmRepository.getById(germplasmId);
+
+        // TODO replace this block by the above commented one
+        GermplasmVO germplasm = createGermplasm();
+
+        if (germplasm == null) {
+            throw new NotFoundException("Germplasm with ID " + germplasmId + " not found");
+        }
+
+        return toModelAndView(germplasm);
+    }
+
+    @GetMapping(params = "pui")
+    public ModelAndView getByPui(@RequestParam("pui") String pui) {
+        GermplasmGETSearchCriteria criteria = new GermplasmGETSearchCriteria();
+        criteria.setGermplasmPUI(Collections.singletonList(pui));
+        List<GermplasmVO> germplasms = germplasmRepository.find(criteria);
+        if (germplasms.size() != 1) {
+            throw new NotFoundException("Germplasm with PUI " + pui + " not found");
+        }
+
+        return toModelAndView(germplasms.get(0));
+    }
+
+    private ModelAndView toModelAndView(GermplasmVO germplasm) {
+        // List<BrapiGermplasmAttributeValue> attributes = getAttributes(germplasm);
+        // List<XRefDocumentVO> crossReferences = xRefDocumentRepository.find(
+        //     XRefDocumentSearchCriteria.forXRefId(site.getLocationDbId()));
+        // PedigreeVO pedigree = getPedigree(germplasm);
+        // List<XRefDocumentVO> crossReferences = xRefDocumentRepository.find(
+        //     XRefDocumentSearchCriteria.forXRefId(germplasm.getGermplasmDbId())
+        // );
+
+        // TODO replace this block by the above commented one
+        List<BrapiGermplasmAttributeValue> attributes = Arrays.asList(
+            createAttribute()
+        );
+        PedigreeVO pedigree = createPedigree();
+        List<XRefDocumentVO> crossReferences = Arrays.asList(
+            createXref("foobar"),
+            createXref("bazbing")
+        );
+
+
+        sortDonors(germplasm);
+        sortPopulations(germplasm);
+        sortCollections(germplasm);
+        sortPanels(germplasm);
+        return new ModelAndView("germplasm",
+                                "model",
+                                new GermplasmModel(
+                                    germplasm,
+                                    faidareProperties.getByUri(germplasm.getSourceUri()),
+                                    attributes,
+                                    pedigree,
+                                    crossReferences
+                                )
+        );
+    }
+
+    private void sortPopulations(GermplasmVO germplasm) {
+        if (germplasm.getPopulation() != null) {
+            germplasm.setPopulation(germplasm.getPopulation()
+                                             .stream()
+                                             .sorted(Comparator.comparing(
+                                                 CollPopVO::getName))
+                                             .collect(Collectors.toList()));
+        }
+    }
+
+    private void sortCollections(GermplasmVO germplasm) {
+        if (germplasm.getCollection() != null) {
+            germplasm.setCollection(germplasm.getCollection()
+                                             .stream()
+                                             .sorted(Comparator.comparing(CollPopVO::getName))
+                                             .collect(Collectors.toList()));
+        }
+    }
+
+    private void sortPanels(GermplasmVO germplasm) {
+        if (germplasm.getPanel() != null) {
+            germplasm.setPanel(germplasm.getPanel()
+                                        .stream()
+                                        .sorted(Comparator.comparing(CollPopVO::getName))
+                                        .collect(Collectors.toList()));
+        }
+    }
+
+    private void sortDonors(GermplasmVO germplasm) {
+        if (germplasm.getDonors() != null) {
+            germplasm.setDonors(germplasm.getDonors()
+                                         .stream()
+                                         .sorted(Comparator.comparing(donor -> donor.getDonorInstitute()
+                                                                                    .getInstituteName()))
+                                         .collect(Collectors.toList()));
+        }
+    }
+
+    private List<BrapiGermplasmAttributeValue> getAttributes(GermplasmVO germplasm) {
+        GermplasmAttributeCriteria criteria = new GermplasmAttributeCriteria();
+        criteria.setGermplasmDbId(germplasm.getGermplasmDbId());
+        return germplasmAttributeRepository.find(criteria)
+            .stream()
+            .flatMap(vo -> vo.getData().stream())
+            .sorted(Comparator.comparing(BrapiGermplasmAttributeValue::getAttributeName))
+            .collect(Collectors.toList());
+    }
+
+    private PedigreeVO getPedigree(GermplasmVO germplasm) {
+        return germplasmRepository.findPedigree(germplasm.getGermplasmDbId());
+    }
+
+    private BrapiGermplasmAttributeValue createAttribute() {
+        GermplasmAttributeValueVO result = new GermplasmAttributeValueVO();
+        result.setAttributeName("A1");
+        result.setValue("V1");
+        return result;
+    }
+
+    private GermplasmVO createGermplasm() {
+        GermplasmVO result = new GermplasmVO();
+
+        result.setGermplasmName("BLE BARBU DU ROUSSILLON");
+        result.setAccessionNumber("1408");
+        result.setSynonyms(Arrays.asList("BLE DU ROUSSILLON", "FRA051:1699", "ROUSSILLON"));
+        PhotoVO photo = new PhotoVO();
+        photo.setPhotoName("Blé du roussillon");
+        photo.setCopyright("INRA, Emmanuelle BOULAT/Lionel BARDY 2012");
+        photo.setThumbnailFile("https://urgi.versailles.inrae.fr/files/siregal/images/accession/CEREALS/thumbnails/thumb_1408_R09_S.jpg");
+        photo.setFile("https://urgi.versailles.inrae.fr/files/siregal/images/accession/CEREALS/1408_R09_S.jpg");
+        result.setPhoto(photo);
+
+        InstituteVO holdingGenBank = new InstituteVO();
+        holdingGenBank.setLogo("https://urgi.versailles.inra.fr/files/siregal/images/grc/inra_brc_en.png");
+        holdingGenBank.setInstituteName("INRA BRC");
+        holdingGenBank.setWebSite("http://google.fr");
+        result.setHoldingGenbank(holdingGenBank);
+
+        result.setBiologicalStatusOfAccessionCode("Traditional cultivar/landrace ");
+        result.setPedigree("LV");
+        SiteVO originSite = new SiteVO();
+        originSite.setSiteId("1234");
+        originSite.setSiteName("Le Moulon");
+        result.setOriginSite(originSite);
+
+        result.setGenus("Genus 1");
+        result.setSpecies("Species 1");
+        result.setSpeciesAuthority("Species Auth");
+        result.setSourceUri("https://urgi.versailles.inrae.fr/gnpis");
+        result.setSubtaxa("Subtaxa 1");
+        result.setGenusSpeciesSubtaxa("Triticum aestivum subsp. aestivum");
+        result.setSubtaxaAuthority("INRAE");
+        result.setTaxonIds(Arrays.asList(createTaxonId(), createTaxonId()));
+        result.setTaxonComment("C'est bon le blé");
+        result.setTaxonCommonNames(Arrays.asList("Blé tendre", "Bread wheat", "Soft wheat"));
+        result.setTaxonSynonyms(Arrays.asList("Blé tendre1", "Bread wheat1", "Soft wheat1"));
+
+        InstituteVO holdingInstitute = new InstituteVO();
+        holdingInstitute.setInstituteName("GDEC - UMR Génétique, Diversité et Ecophysiologie des Céréales");
+        holdingInstitute.setLogo("https://urgi.versailles.inra.fr/files/siregal/images/grc/inra_brc_en.png");
+        holdingInstitute.setWebSite("https://google.fr/q=qsdqsdqsdslqlsdnqlsdqlsdlqskdlqdqlsdqsdqsdqd");
+        holdingInstitute.setInstituteCode("GDEC");
+        holdingInstitute.setInstituteType("Type1");
+        holdingInstitute.setAcronym("G.D.E.C");
+        holdingInstitute.setAddress("Lyon");
+        holdingInstitute.setOrganisation("SAS");
+        result.setHoldingInstitute(holdingInstitute);
+
+        result.setPresenceStatus("Maintained");
+
+        GermplasmInstituteVO collector = new GermplasmInstituteVO();
+        collector.setMaterialType("Fork");
+        collector.setCollectors("Joe, Jack, William, Averell");
+        InstituteVO collectingInstitute = new InstituteVO();
+        collectingInstitute.setInstituteName("Ninja Squad");
+        collector.setInstitute(collectingInstitute);
+        collector.setAccessionNumber("567");
+        result.setCollector(collector);
+
+        result.setCollectingSite(originSite);
+        result.setAcquisitionDate("In the summer");
+
+        GermplasmInstituteVO breeder = new GermplasmInstituteVO();
+        InstituteVO breedingInstitute = new InstituteVO();
+        breedingInstitute.setInstituteName("Microsoft");
+        breeder.setInstitute(breedingInstitute);
+        breeder.setAccessionCreationDate(2015);
+        breeder.setAccessionNumber("678");
+        breeder.setRegistrationYear(2016);
+        breeder.setDeregistrationYear(2019);
+        result.setBreeder(breeder);
+
+        result.setDonors(Arrays.asList(
+            createDonor()
+        ));
+
+        result.setDistributors(Arrays.asList(
+            createDistributor()
+        ));
+
+        result.setChildren(Arrays.asList(createChild(), createChild()));
+
+        result.setGermplasmPUI("germplasmPUI");
+        result.setPopulation(Arrays.asList(createPopulation1(), createPopulation2(), createPopulation3()));
+
+        result.setCollection(Arrays.asList(createCollection()));
+
+        result.setPanel(Arrays.asList(createPanel()));
+
+        return result;
+    }
+
+    private DonorVO createDonor() {
+        DonorVO result = new DonorVO();
+        result.setDonorGermplasmPUI("PUI1");
+        result.setDonationDate(2017);
+        result.setDonorAccessionNumber("3456");
+        result.setDonorInstituteCode("GD46U");
+        InstituteVO institute = new InstituteVO();
+        institute.setInstituteName("Hello");
+        result.setDonorInstitute(institute);
+        return result;
+    }
+
+    private GermplasmInstituteVO createDistributor() {
+        GermplasmInstituteVO result = new GermplasmInstituteVO();
+        InstituteVO institute = new InstituteVO();
+        institute.setInstituteName("Microsoft");
+        result.setInstitute(institute);
+        result.setAccessionNumber("678");
+        result.setDistributionStatus("OK");
+        return result;
+    }
+
+    private PedigreeVO createPedigree() {
+        PedigreeVO result = new PedigreeVO();
+        result.setPedigree("Pedigree 1");
+        result.setParent1DbId("12345");
+        result.setParent1Name("Parent 1");
+        result.setParent1Type("P1");
+        result.setParent2DbId("12346");
+        result.setParent2Name("Parent 2");
+        result.setParent2Type("P2");
+        result.setCrossingPlan("crossing plan 1");
+        result.setCrossingYear("2012");
+        result.setSiblings(Arrays.asList(createBrapiSibling()));
+        return result;
+    }
+
+    private BrapiSibling createBrapiSibling() {
+        SiblingVO sibling = new SiblingVO();
+        sibling.setGermplasmDbId("5678");
+        sibling.setDefaultDisplayName("Sibling 5678");
+        return sibling;
+    }
+
+    private GenealogyVO createChild() {
+        GenealogyVO result = new GenealogyVO();
+        result.setFirstParentName("CP1");
+        result.setSecondParentName("CP2");
+        result.setSibblings(Arrays.asList(createPuiNameValueVO(), createPuiNameValueVO()));
+        return result;
+    }
+
+    private PuiNameValueVO createPuiNameValueVO() {
+        PuiNameValueVO result = new PuiNameValueVO();
+        result.setName("Child 1");
+        result.setPui("pui1");
+        return result;
+    }
+
+    private CollPopVO createPopulation1() {
+        CollPopVO result = new CollPopVO();
+        result.setName("Population 1");
+        result.setType("Pop Type 1");
+        result.setGermplasmCount(3);
+        result.setGermplasmRef(createPuiNameValueVO());
+        return result;
+    }
+
+    private CollPopVO createPopulation2() {
+        CollPopVO result = new CollPopVO();
+        result.setName("Population 2");
+        result.setGermplasmCount(3);
+        PuiNameValueVO puiNameValueVO = createPuiNameValueVO();
+        puiNameValueVO.setPui("germplasmPUI");
+        result.setGermplasmRef(puiNameValueVO);
+        return result;
+    }
+
+    private CollPopVO createPopulation3() {
+        CollPopVO result = new CollPopVO();
+        result.setName("Population 3");
+        result.setGermplasmCount(5);
+        return result;
+    }
+
+    private CollPopVO createCollection() {
+        CollPopVO result = new CollPopVO();
+        result.setName("Collection 1");
+        result.setGermplasmCount(7);
+        return result;
+    }
+
+    private CollPopVO createPanel() {
+        CollPopVO result = new CollPopVO();
+        result.setName("The_panel_1");
+        result.setGermplasmCount(2);
+        return result;
+    }
+
+    private TaxonSourceVO createTaxonId() {
+        TaxonSourceVO result = new TaxonSourceVO();
+        result.setTaxonId("taxon1");
+        result.setSourceName("ThePlantList");
+        return result;
+    }
+
+    private XRefDocumentVO createXref(String name) {
+        XRefDocumentVO xref = new XRefDocumentVO();
+        xref.setName(name);
+        xref.setDescription("A very large description for the xref " + name + " which has way more than 120 characters bla bla bla bla bla bla bla bla bla bla bla bla");
+        xref.setDatabaseName("db_" + name);
+        xref.setUrl("https://google.com");
+        xref.setEntryType("type " + name);
+        return xref;
+    }
+}
diff --git a/backend/src/main/java/fr/inra/urgi/faidare/web/germplasm/GermplasmModel.java b/backend/src/main/java/fr/inra/urgi/faidare/web/germplasm/GermplasmModel.java
new file mode 100644
index 00000000..8acdf78f
--- /dev/null
+++ b/backend/src/main/java/fr/inra/urgi/faidare/web/germplasm/GermplasmModel.java
@@ -0,0 +1,139 @@
+package fr.inra.urgi.faidare.web.germplasm;
+
+import java.util.List;
+
+import fr.inra.urgi.faidare.domain.brapi.v1.data.BrapiGermplasmAttributeValue;
+import fr.inra.urgi.faidare.domain.data.germplasm.GermplasmInstituteVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.GermplasmVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.PedigreeVO;
+import fr.inra.urgi.faidare.domain.datadiscovery.data.DataSource;
+import fr.inra.urgi.faidare.domain.xref.XRefDocumentVO;
+import org.apache.logging.log4j.util.Strings;
+
+/**
+ * The model used by the germplasm page
+ * @author JB Nizet
+ */
+public final class GermplasmModel {
+    private final GermplasmVO germplasm;
+    private final DataSource source;
+    private final List<BrapiGermplasmAttributeValue> attributes;
+    private final PedigreeVO pedigree;
+    private final List<XRefDocumentVO> crossReferences;
+
+    public GermplasmModel(GermplasmVO germplasm,
+                          DataSource source,
+                          List<BrapiGermplasmAttributeValue> attributes,
+                          PedigreeVO pedigree,
+                          List<XRefDocumentVO> crossReferences) {
+        this.germplasm = germplasm;
+        this.source = source;
+        this.attributes = attributes;
+        this.pedigree = pedigree;
+        this.crossReferences = crossReferences;
+    }
+
+    public GermplasmVO getGermplasm() {
+        return germplasm;
+    }
+
+    public DataSource getSource() {
+        return source;
+    }
+
+    public List<BrapiGermplasmAttributeValue> getAttributes() {
+        return attributes;
+    }
+
+    public PedigreeVO getPedigree() {
+        return pedigree;
+    }
+
+    public List<XRefDocumentVO> getCrossReferences() {
+        return crossReferences;
+    }
+
+    public String getTaxon() {
+        if (Strings.isNotBlank(this.germplasm.getGenusSpeciesSubtaxa())) {
+            return this.germplasm.getGenusSpeciesSubtaxa();
+        } else if (Strings.isNotBlank(this.germplasm.getGenusSpecies())) {
+            return this.germplasm.getGenusSpecies();
+        } else if (Strings.isNotBlank(this.germplasm.getSubtaxa())) {
+            return this.germplasm.getGenus() + " " + this.germplasm.getSpecies() + " " + this.germplasm.getSubtaxa();
+        } else if (Strings.isNotBlank(this.germplasm.getSpecies())) {
+            return this.germplasm.getGenus() + " " + this.germplasm.getSpecies();
+        } else {
+            return this.germplasm.getGenus();
+        }
+    }
+
+    public String getTaxonAuthor() {
+        if (Strings.isNotBlank(this.germplasm.getGenusSpeciesSubtaxa())) {
+            return this.germplasm.getSubtaxaAuthority();
+        } else if (Strings.isNotBlank(this.germplasm.getGenusSpecies())) {
+            return this.germplasm.getSpeciesAuthority();
+        } else if (Strings.isNotBlank(this.germplasm.getSubtaxa())) {
+            return this.germplasm.getSubtaxaAuthority();
+        } else if (Strings.isNotBlank(this.germplasm.getSpecies())) {
+            return this.germplasm.getSpeciesAuthority();
+        } else {
+            return null;
+        }
+    }
+
+    public boolean isCollecting() {
+        return this.isCollectingSitePresent()
+            || this.isCollectorInstitutePresent()
+            || this.isCollectorIntituteFieldPresent();
+    }
+
+    private boolean isCollectingSitePresent() {
+        return this.germplasm.getCollectingSite() != null && Strings.isNotBlank(this.germplasm.getCollectingSite().getSiteName());
+    }
+
+    private boolean isCollectorInstitutePresent() {
+        return this.germplasm.getCollector() != null &&
+            this.germplasm.getCollector().getInstitute() != null &&
+            Strings.isNotBlank(this.germplasm.getCollector().getInstitute().getInstituteName());
+    }
+
+    private boolean isCollectorIntituteFieldPresent() {
+        GermplasmInstituteVO collector = this.germplasm.getCollector();
+        return (collector != null) &&
+            (Strings.isNotBlank(collector.getAccessionNumber())
+                || collector.getAccessionCreationDate() != null
+                || Strings.isNotBlank(collector.getMaterialType())
+                || Strings.isNotBlank(collector.getCollectors())
+                || collector.getRegistrationYear() != null
+                || collector.getDeregistrationYear() != null
+                || Strings.isNotBlank(collector.getDistributionStatus())
+            );
+    }
+
+    public boolean isBreeding() {
+        GermplasmInstituteVO breeder = this.germplasm.getBreeder();
+        return breeder != null &&
+            ((breeder.getInstitute() != null && Strings.isNotBlank(breeder.getInstitute().getInstituteName())) ||
+                breeder.getAccessionCreationDate() != null ||
+                Strings.isNotBlank(breeder.getAccessionNumber()) ||
+                breeder.getRegistrationYear() != null ||
+                breeder.getDeregistrationYear() != null);
+    }
+
+    public boolean isGenealogyPresent() {
+        return isPedigreePresent() || isProgenyPresent();
+    }
+
+    private boolean isProgenyPresent() {
+        return germplasm.getChildren() != null && !germplasm.getChildren().isEmpty();
+    }
+
+    private boolean isPedigreePresent() {
+        return this.pedigree != null &&
+            (Strings.isNotBlank(this.pedigree.getParent1Name())
+            || Strings.isNotBlank(this.pedigree.getParent2Name())
+            || Strings.isNotBlank(this.pedigree.getCrossingPlan())
+            || Strings.isNotBlank(this.pedigree.getCrossingYear())
+            || Strings.isNotBlank(this.pedigree.getFamilyCode()));
+    }
+}
diff --git a/backend/src/main/java/fr/inra/urgi/faidare/web/site/SiteController.java b/backend/src/main/java/fr/inra/urgi/faidare/web/site/SiteController.java
index 1ed29438..151da527 100644
--- a/backend/src/main/java/fr/inra/urgi/faidare/web/site/SiteController.java
+++ b/backend/src/main/java/fr/inra/urgi/faidare/web/site/SiteController.java
@@ -37,7 +37,7 @@ public class SiteController {
     }
 
     @GetMapping("/{siteId}")
-    public ModelAndView site(@PathVariable("siteId") String siteId) {
+    public ModelAndView get(@PathVariable("siteId") String siteId) {
         LocationVO site = locationRepository.getById(siteId);
 
         // List<XRefDocumentVO> crossReferences = xRefDocumentRepository.find(
diff --git a/backend/src/main/java/fr/inra/urgi/faidare/web/study/StudyController.java b/backend/src/main/java/fr/inra/urgi/faidare/web/study/StudyController.java
index 35767b09..ff6aee3b 100644
--- a/backend/src/main/java/fr/inra/urgi/faidare/web/study/StudyController.java
+++ b/backend/src/main/java/fr/inra/urgi/faidare/web/study/StudyController.java
@@ -58,7 +58,7 @@ public class StudyController {
     }
 
     @GetMapping("/{studyId}")
-    public ModelAndView site(@PathVariable("studyId") String studyId) {
+    public ModelAndView get(@PathVariable("studyId") String studyId) {
         StudyDetailVO study = studyRepository.getById(studyId);
 
         // List<XRefDocumentVO> crossReferences = xRefDocumentRepository.find(
diff --git a/backend/src/main/java/fr/inra/urgi/faidare/web/thymeleaf/FaidareDialect.java b/backend/src/main/java/fr/inra/urgi/faidare/web/thymeleaf/FaidareDialect.java
new file mode 100644
index 00000000..2a02a842
--- /dev/null
+++ b/backend/src/main/java/fr/inra/urgi/faidare/web/thymeleaf/FaidareDialect.java
@@ -0,0 +1,25 @@
+package fr.inra.urgi.faidare.web.thymeleaf;
+
+import org.springframework.stereotype.Component;
+import org.thymeleaf.dialect.AbstractDialect;
+import org.thymeleaf.dialect.IExpressionObjectDialect;
+import org.thymeleaf.expression.IExpressionObjectFactory;
+
+/**
+ * A thymeleaf dialect allowing to perform various tasks in the template related to Faidare
+ * @author JB Nizet
+ */
+@Component
+public class FaidareDialect extends AbstractDialect implements IExpressionObjectDialect {
+
+    private final IExpressionObjectFactory FAIDARE_EXPRESSION_OBJECTS_FACTORY = new FaidareExpressionFactory();
+
+    protected FaidareDialect() {
+        super("faidare");
+    }
+
+    @Override
+    public IExpressionObjectFactory getExpressionObjectFactory() {
+        return FAIDARE_EXPRESSION_OBJECTS_FACTORY;
+    }
+}
diff --git a/backend/src/main/java/fr/inra/urgi/faidare/web/thymeleaf/FaidareExpressionFactory.java b/backend/src/main/java/fr/inra/urgi/faidare/web/thymeleaf/FaidareExpressionFactory.java
new file mode 100644
index 00000000..e873375c
--- /dev/null
+++ b/backend/src/main/java/fr/inra/urgi/faidare/web/thymeleaf/FaidareExpressionFactory.java
@@ -0,0 +1,33 @@
+package fr.inra.urgi.faidare.web.thymeleaf;
+
+import java.util.Collections;
+import java.util.Set;
+
+import org.thymeleaf.context.IExpressionContext;
+import org.thymeleaf.expression.IExpressionObjectFactory;
+
+/**
+ * The object factory for the {@link FaidareDialect}
+ * @author JB Nizet
+ */
+public class FaidareExpressionFactory implements IExpressionObjectFactory {
+    private static final String FAIDARE_EVALUATION_VARIABLE_NAME = "faidare";
+
+    private static final Set<String> ALL_EXPRESSION_OBJECT_NAMES =
+        Collections.singleton(FAIDARE_EVALUATION_VARIABLE_NAME);
+
+    @Override
+    public Set<String> getAllExpressionObjectNames() {
+        return ALL_EXPRESSION_OBJECT_NAMES;
+    }
+
+    @Override
+    public Object buildObject(IExpressionContext context, String expressionObjectName) {
+        return new FaidareExpressions(context.getLocale());
+    }
+
+    @Override
+    public boolean isCacheable(String expressionObjectName) {
+        return true;
+    }
+}
diff --git a/backend/src/main/java/fr/inra/urgi/faidare/web/thymeleaf/FaidareExpressions.java b/backend/src/main/java/fr/inra/urgi/faidare/web/thymeleaf/FaidareExpressions.java
new file mode 100644
index 00000000..a9f699de
--- /dev/null
+++ b/backend/src/main/java/fr/inra/urgi/faidare/web/thymeleaf/FaidareExpressions.java
@@ -0,0 +1,65 @@
+package fr.inra.urgi.faidare.web.thymeleaf;
+
+import java.nio.charset.StandardCharsets;
+import java.text.DecimalFormat;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.Function;
+
+import fr.inra.urgi.faidare.domain.data.germplasm.CollPopVO;
+import fr.inra.urgi.faidare.domain.data.germplasm.TaxonSourceVO;
+import org.apache.logging.log4j.util.Strings;
+
+/**
+ * The actual object offering Faidare helper methods to thymeleaf
+ * @author JB Nizet
+ */
+public class FaidareExpressions {
+
+    private static final Map<String, Function<String, String>> TAXON_ID_URL_FACTORIES_BY_SOURCE_NAME =
+        createTaxonIdUrlFactories();
+
+    private static Map<String, Function<String, String>> createTaxonIdUrlFactories() {
+        Map<String, Function<String, String>> result = new HashMap<>();
+        result.put("NCBI", s -> "https://www.ncbi.nlm.nih.gov/Taxonomy/Browser/wwwtax.cgi?mode=Info&id=" + s);
+        result.put("ThePlantList", s -> "http://www.theplantlist.org/tpl1.1/record/" + s);
+        result.put("TAXREF", s -> "https://inpn.mnhn.fr/espece/cd_nom/" + s);
+        result.put("CatalogueOfLife", s -> "http://www.catalogueoflife.org/col/details/species/id/" + s);
+        return Collections.unmodifiableMap(result);
+    }
+
+    private final Locale locale;
+
+    public FaidareExpressions(Locale locale) {
+        this.locale = locale;
+    }
+
+    public String toSiteParam(String siteId) {
+        return Base64.getUrlEncoder().encodeToString(("urn:URGI/location/" + siteId).getBytes(StandardCharsets.US_ASCII));
+    }
+
+    public String collPopTitle(CollPopVO collPopVO) {
+        return collPopTitle(collPopVO, Function.identity());
+    }
+
+    public String collPopTitleWithoutUnderscores(CollPopVO collPopVO) {
+        return collPopTitle(collPopVO, s -> s.replace('_', ' '));
+    }
+
+    public String taxonIdUrl(TaxonSourceVO taxonSource) {
+        Function<String, String> urlFactory =
+            TAXON_ID_URL_FACTORIES_BY_SOURCE_NAME.get(taxonSource.getSourceName());
+        return urlFactory != null ? urlFactory.apply(taxonSource.getTaxonId()) : null;
+    }
+
+    private String collPopTitle(CollPopVO collPopVO, Function<String, String> nameTransformer) {
+        if (Strings.isBlank(collPopVO.getType())) {
+            return nameTransformer.apply(collPopVO.getName());
+        } else {
+            return nameTransformer.apply(collPopVO.getName()) + " (" + collPopVO.getType() + ")";
+        }
+    }
+}
diff --git a/backend/src/main/resources/static/assets/style.css b/backend/src/main/resources/static/assets/style.css
index 59bcd117..340b22ea 100644
--- a/backend/src/main/resources/static/assets/style.css
+++ b/backend/src/main/resources/static/assets/style.css
@@ -1,3 +1,7 @@
 .label {
     font-weight: 500;
 }
+
+.popover {
+    max-width: min(80vw, 600px);
+}
diff --git a/backend/src/main/resources/templates/fragments/institute.html b/backend/src/main/resources/templates/fragments/institute.html
new file mode 100644
index 00000000..0efa76dd
--- /dev/null
+++ b/backend/src/main/resources/templates/fragments/institute.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+
+<html xmlns:th="http://www.thymeleaf.org">
+
+<body>
+
+<!--
+Reusable fragment displaying the content of an institute popover.
+Its unique argument (institute) is an InstituteVO
+-->
+
+<th:block th:fragment="institute(institute)">
+  <div class="text-center py-2" th:if="${institute.logo}">
+    <img th:src="${institute.logo}" />
+  </div>
+  <div th:replace="fragments/row::text-row(label='Code', text=${institute.instituteCode})"></div>
+  <div th:replace="fragments/row::text-row(label='Acronym', text=${institute.acronym})"></div>
+  <div th:replace="fragments/row::text-row(label='Organization', text=${institute.organisation})"></div>
+  <div th:replace="fragments/row::text-row(label='Type', text=${institute.instituteType})"></div>
+  <div th:replace="fragments/row::text-row(label='Address', text=${institute.address})"></div>
+
+  <th:block th:if="${institute.webSite}">
+    <div th:replace="fragments/row::row(label='Website', content=~{::.institute-website})">
+      <a class="institute-website"
+         target="_blank"
+         th:href="${institute.webSite}"
+         th:text="${#strings.abbreviate(institute.webSite, 25)}"></a>
+    </div>
+  </th:block>
+</th:block>
+
+</body>
+
+</html>
diff --git a/backend/src/main/resources/templates/fragments/link.html b/backend/src/main/resources/templates/fragments/link.html
new file mode 100644
index 00000000..d7c43bbd
--- /dev/null
+++ b/backend/src/main/resources/templates/fragments/link.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+
+<html xmlns:th="http://www.thymeleaf.org">
+
+<body>
+<!--
+Reusable fragment displaying a link with a label if the provided url is not
+empty, or a span with the label if the provided url is empty.
+Both arguments are strings.
+-->
+<th:block th:fragment="link(label, url)">
+  <a th:unless="${#strings.isEmpty(url)}"
+     th:href="${url}"
+     th:text="${label}"></a>
+  <span th:if="${#strings.isEmpty(url)}" th:text="${label}"></span>
+</th:block>
+
+</body>
+
+</html>
diff --git a/backend/src/main/resources/templates/fragments/row.html b/backend/src/main/resources/templates/fragments/row.html
index 5b523cef..e5eb9e7f 100644
--- a/backend/src/main/resources/templates/fragments/row.html
+++ b/backend/src/main/resources/templates/fragments/row.html
@@ -4,6 +4,21 @@
 
 <body>
 
+<!--
+Reusable fragment displaying a responsive row containing a label and a content.
+The label argument is a string.
+The content argument is a fragment which is displayed at the right of the label.
+
+Note that `th:if` is not evaluated when th:replace is used. So if this row must
+be displayed only if some condition is true, the fragment should be enclosed
+into a block with the condition:
+  <th:block th:if="${someCondition}">
+    <div th:replace="fragments/row::row(label='Some label', content=~{::#some-content-id})">
+      <span id="some-content-id">the content here</span>
+    </div>
+  </th:block>
+-->
+
 <div th:fragment="row(label, content)" class="row py-2">
   <div class="col-md-4 label pb-1 pb-md-0" th:text="${label}"></div>
   <div class="col">
@@ -11,7 +26,21 @@
   </div>
 </div>
 
-<div th:fragment="text-row(label, text)" th:if="${!#strings.isEmpty(text)}" class="row py-2">
+<!--
+Reusable fragment displaying a responsive row containing a label and a textual content.
+The label argument is a string.
+The text argument is a string which is displayed at the right of the label.
+The whole row is omitted if the textual content is empty, so the caller does not
+need to test that condition.
+
+Note that `th:if` is not evaluated when th:replace is used. So if this row must
+be displayed only if some other condition is true, the fragment should be enclosed
+into a block with the condition:
+  <th:block th:if="${someCondition}">
+    <div th:replace="fragments/row::text-row(label='Some label', text=${someTextExpression})"></div>
+  </th:block>
+-->
+<div th:fragment="text-row(label, text)" th:unless="${#strings.isEmpty(text)}" class="row py-2">
   <div class="col-md-4 label pb-1 pb-md-0" th:text="${label}"></div>
   <div class="col" th:text="${text}"></div>
 </div>
diff --git a/backend/src/main/resources/templates/fragments/source.html b/backend/src/main/resources/templates/fragments/source.html
new file mode 100644
index 00000000..795717ca
--- /dev/null
+++ b/backend/src/main/resources/templates/fragments/source.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+
+<html xmlns:th="http://www.thymeleaf.org">
+
+<body>
+
+<!--
+Reusable fragment displaying the source and the data links of an entity (site, study or germplasm).
+The source argument is a DataSource.
+The url argument is a string, which is the URL of the entity.
+The entityType argument is a string, which is used in the message
+"Link to this <entityType>".
+-->
+
+<th:block th:fragment="source(source, url, entityType)">
+  <th:block th:if="${source != null}">
+    <div th:replace="fragments/row::row(label='Source', content=~{::.source})">
+      <a class="source" target="_blank" th:href="${source.url}">
+        <img style="max-height: 60px;" th:src="${source.image}" th:alt="${source.name} + ' logo'" />
+      </a>
+    </div>
+  </th:block>
+
+  <th:block th:if="${url != null && source != null}">
+    <div th:replace="fragments/row::row(label='Data link', content=~{::.source-url})">
+      <a class="source-url" target="_blank" th:href="${url}">
+        Link to this <span th:text="${entityType}"></span> on <th:block th:text="${source.name}" />
+      </a>
+    </div>
+  </th:block>
+</th:block>
+
+</body>
+
+</html>
diff --git a/backend/src/main/resources/templates/fragments/xrefs.html b/backend/src/main/resources/templates/fragments/xrefs.html
index d51f1186..508b8923 100644
--- a/backend/src/main/resources/templates/fragments/xrefs.html
+++ b/backend/src/main/resources/templates/fragments/xrefs.html
@@ -4,6 +4,11 @@
 
 <body>
 
+<!--
+Reusable fragment displaying a cross references section, with its title.
+The unique argument (crossReferences) is a List<XRefDocumentVO>
+-->
+
 <div th:fragment="xrefs(crossReferences)" th:if="${!#lists.isEmpty(crossReferences)}">
   <h2>Cross references</h2>
 
diff --git a/backend/src/main/resources/templates/germplasm.html b/backend/src/main/resources/templates/germplasm.html
new file mode 100644
index 00000000..840b0d44
--- /dev/null
+++ b/backend/src/main/resources/templates/germplasm.html
@@ -0,0 +1,418 @@
+<!DOCTYPE html>
+
+<html
+  xmlns:th="http://www.thymeleaf.org"
+  th:replace="~{layout/main :: layout(title=~{::title}, content=~{::main})}"
+>
+<head>
+  <title>Germplasm: <th:block th:text="${model.germplasm.germplasmName}" /></title>
+  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+</head>
+
+<body>
+<main>
+  <div class="d-flex">
+    <h1 class="flex-grow-1">Germplasm: <th:block th:text="${model.germplasm.germplasmName}" /></h1>
+    <div th:if="${model.germplasm.holdingGenbank != null && model.germplasm.holdingGenbank.logo != null}">
+      <img th:src="${model.germplasm.holdingGenbank.logo}" th:alt="${model.germplasm.holdingGenbank.instituteName}" />
+    </div>
+  </div>
+
+  <div class="row align-items-center justify-content-center">
+    <div class="col-auto field" th:if="${model.germplasm.photo != null && model.germplasm.photo.thumbnailFile != null}">
+      <template id="photo-popover">
+        <div class="card">
+          <img th:src="${model.germplasm.photo.file}" class="card-img-top" alt="">
+          <div class="card-body">
+            <div th:replace="fragments/row::text-row(label='Accession name', text=${model.germplasm.germplasmName})"></div>
+            <div th:replace="fragments/row::text-row(label='Photo name', text=${model.germplasm.photo.photoName})"></div>
+            <div th:replace="fragments/row::text-row(label='Description', text=${model.germplasm.photo.description})"></div>
+            <div th:replace="fragments/row::text-row(label='Copyright', text=${model.germplasm.photo.copyright})"></div>
+          </div>
+        </div>
+      </template>
+
+      <button class="btn btn-link p-0"
+              data-bs-toggle="popover"
+              th:data-bs-title="${model.germplasm.photo.photoName}"
+              data-bs-element="#photo-popover"
+              data-bs-container="body"
+              data-bs-trigger="focus">
+        <img th:src="${model.germplasm.photo.thumbnailFile}" class="img-fluid" />
+
+        <figcaption class="figure-caption">
+          © <span th:text="${model.germplasm.photo.copyright}"></span>
+        </figcaption>
+      </button>
+    </div>
+
+
+    <div class="col-12 col-lg">
+      <h2>Identification</h2>
+
+      <div th:replace="fragments/row::text-row(label='Germplasm name', text=${model.germplasm.germplasmName})"></div>
+      <div th:replace="fragments/row::text-row(label='Accession number', text=${model.germplasm.accessionNumber})"></div>
+
+      <div th:replace="fragments/source::source(source=${model.source}, url=${model.germplasm.url}, entityType='germplasm')"></div>
+
+      <th:block th:unless="${#lists.isEmpty(model.germplasm.synonyms)}">
+        <div th:replace="fragments/row::row(label='Accession synonyms', content=~{::#accession-synonyms})">
+          <div id="accession-synonyms" class="content-overflow" th:text="${#strings.listJoin(model.germplasm.synonyms, ', ')}"></div>
+        </div>
+      </th:block>
+
+      <th:block th:unless="${#strings.isEmpty(model.taxon)}">
+        <div th:replace="fragments/row::row(label='Taxon', content=~{::#taxon})">
+          <div id="taxon">
+            <template id="taxon-popover">
+              <th:block th:unless="${#strings.isEmpty(model.germplasm.genus)}">
+                <div th:replace="fragments/row::row(label='Genus', content=~{::#taxon-genus})">
+                  <em id="taxon-genus" th:text="${model.germplasm.genus}"></em>
+                </div>
+              </th:block>
+              <th:block th:unless="${#strings.isEmpty(model.germplasm.species)}">
+                <div th:replace="fragments/row::row(label='Species', content=~{::#taxon-species})">
+                  <span id="taxon-species">
+                    <em th:text="${model.germplasm.species}"></em>
+                    <span th:unless="${#strings.isEmpty(model.germplasm.speciesAuthority)}"
+                          th:text="${'(' + model.germplasm.speciesAuthority + ')'}"></span>
+                  </span>
+                </div>
+              </th:block>
+              <th:block th:unless="${#strings.isEmpty(model.germplasm.subtaxa)}">
+                <div th:replace="fragments/row::row(label='Subtaxa', content=~{::#taxon-subtaxa})">
+                  <span id="taxon-subtaxa">
+                    <em th:text="${model.germplasm.subtaxa}"></em>
+                    <span th:unless="${#strings.isEmpty(model.germplasm.subtaxaAuthority)}"
+                          th:text="${'(' + model.germplasm.subtaxaAuthority + ')'}"></span>
+                  </span>
+                </div>
+              </th:block>
+
+              <div th:replace="fragments/row::text-row(label='Authority', text=${model.taxonAuthor})"></div>
+
+              <th:block th:unless="${#lists.isEmpty(model.germplasm.taxonIds)}">
+                <div th:replace="fragments/row::row(label='Taxon IDs', content=~{::#taxon-ids})">
+                  <div id="taxon-ids">
+                    <div th:each="taxonId : ${model.germplasm.taxonIds}" class="row">
+                      <div class="col-6 text-nowrap" th:text="${taxonId.sourceName}"></div>
+                      <div class="col-6">
+                        <span class="taxon-id"
+                              th:replace="fragments/link::link(label=${taxonId.taxonId}, url=${#faidare.taxonIdUrl(taxonId)})"></span>
+                      </div>
+                    </div>
+                  </div>
+                </div>
+              </th:block>
+
+              <div th:replace="fragments/row::text-row(label='Comment', text=${model.germplasm.taxonComment})"></div>
+              <th:block th:unless="${#lists.isEmpty(model.germplasm.taxonCommonNames)}">
+                <div th:replace="fragments/row::row(label='Taxon common names', content=~{::#taxon-common-names})">
+                  <div id="taxon-common-names" class="content-overflow" th:text="${#strings.listJoin(model.germplasm.taxonCommonNames, ', ')}"></div>
+                </div>
+              </th:block>
+              <th:block th:unless="${#lists.isEmpty(model.germplasm.taxonSynonyms)}">
+                <div th:replace="fragments/row::row(label='Taxon common names', content=~{::#taxon-synonyms})">
+                  <div id="taxon-synonyms" class="content-overflow" th:text="${#strings.listJoin(model.germplasm.taxonSynonyms, ', ')}"></div>
+                </div>
+              </th:block>
+            </template>
+            <button class="btn btn-link p-0"
+                    data-bs-toggle="popover"
+                    th:data-bs-title="${model.taxon}"
+                    data-bs-element="#taxon-popover"
+                    data-bs-container="body"
+                    data-bs-trigger="focus">
+              <em th:text="${model.taxon}"></em>
+              <th:block th:unless="${#strings.isEmpty(model.taxonAuthor)}">(<span th:text="${model.taxonAuthor}"></span>)</th:block>
+            </button>
+          </div>
+        </div>
+      </th:block>
+
+      <div th:replace="fragments/row::text-row(label='Biological status', text=${model.germplasm.biologicalStatusOfAccessionCode})"></div>
+      <div th:replace="fragments/row::text-row(label='Genetic nature', text=${model.germplasm.geneticNature})"></div>
+      <div th:replace="fragments/row::text-row(label='Seed source', text=${model.germplasm.seedSource})"></div>
+      <div th:replace="fragments/row::text-row(label='Pedigree', text=${model.germplasm.pedigree})"></div>
+      <div th:replace="fragments/row::text-row(label='Comments', text=${model.germplasm.comment})"></div>
+
+      <th:block th:if="${model.germplasm.originSite != null && !#strings.isEmpty(model.germplasm.originSite.siteName)}">
+        <div th:replace="fragments/row::row(label='Origin site', content=~{::#origin-site})">
+          <a id="origin-site" th:href="@{/sites/{siteId}(siteId=${#faidare.toSiteParam(model.germplasm.originSite.siteId)})}" th:text="${model.germplasm.originSite.siteName}"></a>
+        </div>
+      </th:block>
+    </div>
+  </div>
+
+  <th:block th:if="${model.germplasm.holdingInstitute}">
+    <h2>Depositary</h2>
+    <template id="holding-institute-popover">
+      <div th:replace="fragments/institute::institute(institute=${model.germplasm.holdingInstitute})"></div>
+    </template>
+    <div th:replace="fragments/row::row(label='Institution', content=~{::#institution})">
+      <button id="institution"
+              data-bs-toggle="popover"
+              th:data-bs-title="${model.germplasm.holdingInstitute.instituteName}"
+              data-bs-element="#holding-institute-popover"
+              data-bs-container="body"
+              data-bs-trigger="focus"
+              class="btn btn-link p-0"
+              th:text="${model.germplasm.holdingInstitute.instituteName}"></button>
+    </div>
+
+    <th:block th:if="${model.germplasm.holdingGenbank != null && !#strings.isEmpty(model.germplasm.holdingGenbank.instituteName) && !#strings.isEmpty(model.germplasm.holdingGenbank.webSite)}">
+      <div th:replace="fragments/row::row(label='Stock center name', content=~{::#stock-center-name})">
+        <a id="stock-center-name"
+           target="_blank"
+           th:href="${model.germplasm.holdingGenbank.webSite}"
+           th:text="${model.germplasm.holdingGenbank.instituteName}"></a>
+      </div>
+    </th:block>
+
+    <div th:replace="fragments/row::text-row(label='Presence status', text=${model.germplasm.presenceStatus})"></div>
+  </th:block>
+
+  <th:block th:if="${model.collecting}">
+    <h2>Collector</h2>
+    <th:block th:if="${model.germplasm.collectingSite != null && !#strings.isEmpty(model.germplasm.collectingSite.siteName)}">
+      <div th:replace="fragments/row::row(label='Collecting site', content=~{::#collecting-site})">
+        <a id="collecting-site"
+           th:href="@{/sites/{siteId}(siteId=${#faidare.toSiteParam(model.germplasm.collectingSite.siteId)})}"
+           th:text="${model.germplasm.collectingSite.siteName}"
+        ></a>
+      </div>
+    </th:block>
+
+    <div th:replace="fragments/row::text-row(label='Material type', text=${model.germplasm.collector.materialType})"></div>
+    <div th:replace="fragments/row::text-row(label='Collectors', text=${model.germplasm.collector.collectors})"></div>
+
+    <th:block th:if="${!#strings.isEmpty(model.germplasm.acquisitionDate) && model.germplasm.collector.accessionCreationDate == null}">
+      <div th:replace="fragments/row::text-row(label='Acquisition / Creation date', text=${model.germplasm.acquisitionDate})"></div>
+    </th:block>
+
+    <th:block th:if="${model.germplasm.collector.institute != null && !#strings.isEmpty(model.germplasm.collector.institute.instituteName)}">
+      <template id="collector-institute-popover">
+        <div th:replace="fragments/institute::institute(institute=${model.germplasm.collector.institute})"></div>
+      </template>
+      <div th:replace="fragments/row::row(label='Institution', content=~{::#collecting-institution})">
+        <button id="collecting-institution"
+                data-bs-toggle="popover"
+                th:data-bs-title="${model.germplasm.collector.institute.instituteName}"
+                data-bs-element="#collector-institute-popover"
+                data-bs-container="body"
+                data-bs-trigger="focus"
+                class="btn btn-link p-0"
+                th:text="${model.germplasm.collector.institute.instituteName}"></button>
+      </div>
+    </th:block>
+
+    <div th:replace="fragments/row::text-row(label='Accession number', text=${model.germplasm.collector.accessionNumber})"></div>
+  </th:block>
+
+  <th:block th:if="${model.breeding}">
+    <h2>Breeder</h2>
+    <th:block th:if="${model.germplasm.breeder.institute != null && !#strings.isEmpty(model.germplasm.breeder.institute.instituteName)}">
+      <template id="breeder-institute-popover">
+        <div th:replace="fragments/institute::institute(institute=${model.germplasm.breeder.institute})"></div>
+      </template>
+      <div th:replace="fragments/row::row(label='Institute', content=~{::#breeding-institution})">
+        <button id="breeding-institution"
+                data-bs-toggle="popover"
+                th:data-bs-title="${model.germplasm.breeder.institute.instituteName}"
+                data-bs-element="#breeder-institute-popover"
+                data-bs-container="body"
+                data-bs-trigger="focus"
+                class="btn btn-link p-0"
+                th:text="${model.germplasm.breeder.institute.instituteName}"></button>
+      </div>
+    </th:block>
+
+    <div th:replace="fragments/row::text-row(label='Accession creation year', text=${model.germplasm.breeder.accessionCreationDate})"></div>
+    <div th:replace="fragments/row::text-row(label='Accession number', text=${model.germplasm.breeder.accessionNumber})"></div>
+    <div th:replace="fragments/row::text-row(label='Catalog registration year', text=${model.germplasm.breeder.registrationYear})"></div>
+    <div th:replace="fragments/row::text-row(label='Catalog deregistration year', text=${model.germplasm.breeder.deregistrationYear})"></div>
+  </th:block>
+
+  <th:block th:unless="${#lists.isEmpty(model.germplasm.donors)}">
+    <h2>Donors</h2>
+    <div class="table-responsive scroll-table table-card-body">
+      <div class="card">
+        <table class="table table-sm table-striped">
+          <thead>
+            <tr>
+              <th scope="col">Institute name</th>
+              <th scope="col">Institute code</th>
+              <th scope="col">Donation date</th>
+              <th scope="col">Accession number</th>
+              <th scope="col">Accession PUI</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr th:each="row, donorIterStat : ${model.germplasm.donors}">
+              <td>
+                <template th:id="${'donor-institute-popover-' + donorIterStat.index}">
+                  <div th:replace="fragments/institute::institute(institute=${row.donorInstitute})"></div>
+                </template>
+                <button data-bs-toggle="popover"
+                        th:data-bs-title="${row.donorInstitute.instituteName}"
+                        th:data-bs-element="${'#donor-institute-popover-' + donorIterStat.index}"
+                        data-bs-container="body"
+                        data-bs-trigger="focus"
+                        class="btn btn-link p-0"
+                        th:text="${row.donorInstitute.instituteName}"></button>
+              </td>
+              <td th:text="${row.donorInstituteCode}"></td>
+              <td th:text="${row.donationDate}"></td>
+              <td th:text="${row.donorAccessionNumber}"></td>
+              <td th:text="${row.donorGermplasmPUI}"></td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+  </th:block>
+
+  <th:block th:unless="${#lists.isEmpty(model.germplasm.distributors)}">
+    <h2>Donors</h2>
+    <div class="table-responsive scroll-table table-card-body">
+      <div class="card">
+        <table class="table table-sm table-striped">
+          <thead>
+            <tr>
+              <th scope="col">Institute</th>
+              <th scope="col">Accession number</th>
+              <th scope="col">Distribution status</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr th:each="row, distributorIterStat : ${model.germplasm.distributors}">
+              <td>
+                <template th:id="${'distributor-institute-popover-' + distributorIterStat.index}">
+                  <div th:replace="fragments/institute::institute(institute=${row.institute})"></div>
+                </template>
+                <button data-bs-toggle="popover"
+                        th:data-bs-title="${row.institute.instituteName}"
+                        th:data-bs-element="${'#distributor-institute-popover-' + distributorIterStat.index}"
+                        data-bs-container="body"
+                        data-bs-trigger="focus"
+                        class="btn btn-link p-0"
+                        th:text="${row.institute.instituteName}"></button>
+              </td>
+              <td th:text="${row.accessionNumber}"></td>
+              <td th:text="${row.distributionStatus}"></td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </div>
+  </th:block>
+
+  <th:block th:unless="${#lists.isEmpty(model.attributes)}">
+    <h2>Evaluation Data</h2>
+    <th:block th:each="descriptor : ${model.attributes}">
+      <div th:replace="fragments/row::text-row(label=${descriptor.attributeName}, text=${descriptor.value})"></div>
+    </th:block>
+  </th:block>
+
+  <th:block th:if="${model.genealogyPresent}">
+    <h2>Genealogy</h2>
+
+    <th:block th:if="${model.pedigree != null}">
+      <div th:replace="fragments/row::text-row(label='Crossing plan', text=${model.pedigree.crossingPlan})"></div>
+      <div th:replace="fragments/row::text-row(label='Crossing year', text=${model.pedigree.crossingYear})"></div>
+      <div th:replace="fragments/row::text-row(label='Family code', text=${model.pedigree.familyCode})"></div>
+      <th:block th:unless="${#strings.isEmpty(model.pedigree.parent1Name)}">
+        <div th:replace="fragments/row::row(label='Parent accessions', content=~{::#parent-accessions})">
+          <div id="parent-accessions">
+            <th:block th:if="${model.pedigree.parent1DbId}">
+              <div th:replace="fragments/row::row(label=${model.pedigree.parent1Type}, content=~{::#parent1-link})">
+                <a id="parent1-link" th:href="@{/germplasms/{germplasmId}(germplasmId=${model.pedigree.parent1DbId})}" th:text="${model.pedigree.parent1Name}"></a>
+              </div>
+            </th:block>
+
+            <th:block th:if="${model.pedigree.parent2DbId}">
+              <div th:replace="fragments/row::row(label=${model.pedigree.parent2Type}, content=~{::#parent2-link})">
+                <a id="parent2-link" th:href="@{/germplasms/{germplasmId}(germplasmId=${model.pedigree.parent2DbId})}" th:text="${model.pedigree.parent2Name}"></a>
+              </div>
+            </th:block>
+          </div>
+        </div>
+      </th:block>
+
+      <th:block th:unless="${#lists.isEmpty(model.pedigree.siblings)}">
+        <div th:replace="fragments/row::row(label='Sibling accessions', content=~{::#sibling-accessions})">
+          <div id="sibling-accessions" class="content-overflow">
+            <a th:each="sibling : ${model.pedigree.siblings}"
+               th:href="@{/germplasms/{germplasmId}(germplasmId=${sibling.germplasmDbId})}"
+               th:text="${sibling.defaultDisplayName}"></a>
+          </div>
+        </div>
+      </th:block>
+    </th:block>
+
+    <th:block th:unless="${#lists.isEmpty(model.germplasm.children)}">
+      <div th:replace="fragments/row::row(label='Descendants', content=~{::#descendants})">
+        <div id="descendants" class="content-overflow-big">
+          <th:block th:each="child : ${model.germplasm.children}">
+            <div th:replace="fragments/row::row(label=${#strings.isEmpty(child.secondParentName) ? ('children of ' + child.firstParentName) : ('children of ' + child.firstParentName + ' x ' + child.secondParentName) }, content=~{::.descendant-child})">
+              <div class="descendant-child">
+                <th:block th:each="sibling, siblingIterStat : ${child.sibblings}">
+                  <a th:href="@{/germplasms(pui=${sibling.pui})}"
+                     th:text="${sibling.name}"></a><th:block th:unless="${siblingIterStat.last}">, </th:block>
+                </th:block>
+              </div>
+            </div>
+          </th:block>
+        </div>
+      </div>
+    </th:block>
+
+  </th:block>
+
+  <th:block th:unless="${#lists.isEmpty(model.germplasm.population)}">
+    <h2>Population</h2>
+    <th:block th:each="population : ${model.germplasm.population}">
+
+      <th:block th:if="${population.germplasmRef != null}">
+        <th:block th:unless="${#strings.isEmpty(population.germplasmRef.pui)}">
+          <div th:replace="fragments/row::row(label=${#faidare.collPopTitle(population)}, content=~{::.population-1})">
+            <div class="population-1">
+              <a th:if="${population.germplasmRef.pui != model.germplasm.germplasmPUI}"
+                 th:href="@{/germplasms(pui=${population.germplasmRef.pui})}"
+                 th:text="${population.germplasmRef.name}"></a>
+              <span th:if="${population.germplasmRef.pui == model.germplasm.germplasmPUI}"
+                    th:text="${population.germplasmRef.name}"></span>
+              is composed by <span th:text="${population.germplasmCount}"></span> accession(s)
+              <!-- TODO there was a link pointing at a search here -->
+            </div>
+          </div>
+        </th:block>
+      </th:block>
+
+      <th:block th:if="${population.germplasmRef == null}">
+        <div th:replace="fragments/row::text-row(label=${#faidare.collPopTitle(population)}, text=${population.germplasmCount + ' accession(s)'})"></div>
+        <!-- TODO there was a link pointing at a search here -->
+      </th:block>
+    </th:block>
+  </th:block>
+
+  <th:block th:unless="${#lists.isEmpty(model.germplasm.collection)}">
+    <h2>Collection</h2>
+    <th:block th:each="collection : ${model.germplasm.collection}">
+      <div th:replace="fragments/row::text-row(label=${#faidare.collPopTitle(collection)}, text=${collection.germplasmCount + ' accession(s)'})"></div>
+      <!-- TODO there was a link pointing at a search here -->
+    </th:block>
+  </th:block>
+
+  <th:block th:unless="${#lists.isEmpty(model.germplasm.panel)}">
+    <h2>Panel</h2>
+    <th:block th:each="panel : ${model.germplasm.panel}">
+      <div th:replace="fragments/row::text-row(label=${#faidare.collPopTitleWithoutUnderscores(panel)}, text=${panel.germplasmCount + ' accession(s)'})"></div>
+      <!-- TODO there was a link pointing at a search here -->
+    </th:block>
+  </th:block>
+
+  <div th:replace="fragments/xrefs::xrefs(crossReferences=${model.crossReferences})"></div>
+</main>
+</body>
+</html>
diff --git a/backend/src/main/resources/templates/layout/main.html b/backend/src/main/resources/templates/layout/main.html
index 22bcdfd5..4cd33f70 100644
--- a/backend/src/main/resources/templates/layout/main.html
+++ b/backend/src/main/resources/templates/layout/main.html
@@ -26,5 +26,27 @@
         common footer
       </footer>
     </div>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-U1DAWAznBHeqEIlVSCgzq+c9gqGAJn5c/t99JyeKa9xxaYpSvHU5awsuZVVFIhvj" crossorigin="anonymous"></script>
+    <script type="text/javascript">
+      const popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'))
+      popoverTriggerList.forEach(popoverTriggerEl => {
+        const options = {};
+        const contentSelector = popoverTriggerEl.dataset.bsElement;
+        if (contentSelector) {
+          const content = document.querySelector(contentSelector);
+          if (content) {
+            options.content = () => {
+              const element = document.createElement('div');
+              element.innerHTML = content.innerHTML;
+              return element;
+            };
+            options.html = true;
+          } else {
+            throw new Error('element with selector ' + contentSelector + ' not found');
+          }
+        }
+        return new bootstrap.Popover(popoverTriggerEl, options);
+      });
+    </script>
   </body>
 </html>
diff --git a/backend/src/main/resources/templates/site.html b/backend/src/main/resources/templates/site.html
index ad6affc4..d5d65e5c 100644
--- a/backend/src/main/resources/templates/site.html
+++ b/backend/src/main/resources/templates/site.html
@@ -17,22 +17,7 @@
     <div th:replace="fragments/row::text-row(label='Permanent unique identifier', text=${model.site.uri})"></div>
   </th:block>
 
-  <th:block th:if="${model.source != null}">
-    <div th:replace="fragments/row::row(label='Source', content=~{::#source})">
-      <a id="source" target="_blank" th:href="${model.source.url}">
-        <img style="max-height: 60px;" th:src="${model.source.image}" th:alt="${model.source.name} + ' logo'" />
-      </a>
-    </div>
-  </th:block>
-
-  <th:block th:if="${model.site.url != null && model.source != null}">
-    <div
-         th:replace="fragments/row::row(label='Data link', content=~{::#url})">
-      <a id="url" target="_blank" th:href="${model.site.url}">
-        Link to this site on <th:block th:text="${model.source.name}" />
-      </a>
-    </div>
-  </th:block>
+  <div th:replace="fragments/source::source(source=${model.source}, url=${model.site.url}, entityType='site')"></div>
 
   <div th:replace="fragments/row::text-row(label='Abbreviation', text=${model.site.abbreviation})"></div>
   <div th:replace="fragments/row::text-row(label='Type', text=${model.site.locationType})"></div>
diff --git a/backend/src/main/resources/templates/study.html b/backend/src/main/resources/templates/study.html
index a1cafc65..7bd5bbbd 100644
--- a/backend/src/main/resources/templates/study.html
+++ b/backend/src/main/resources/templates/study.html
@@ -18,21 +18,7 @@
   <div th:replace="fragments/row::text-row(label='Name', text=${model.study.studyName})"></div>
   <div th:replace="fragments/row::text-row(label='Identifier', text=${model.study.studyDbId})"></div>
 
-  <th:block th:if="${model.source != null}">
-    <div th:replace="fragments/row::row(label='Source', content=~{::#source}, text='')">
-      <a id="source" target="_blank" th:href="${model.source.url}">
-        <img style="max-height: 60px;" th:src="${model.source.image}" th:alt="${model.source.name} + ' logo'" />
-      </a>
-    </div>
-  </th:block>
-
-  <th:block th:if="${model.study.url != null && model.source != null}">
-    <div th:replace="fragments/row::row(label='Data link', content=~{::#url}, text='')">
-      <a id="url" target="_blank" th:href="${model.study.url}">
-        Link to this study on <th:block th:text="${model.source.name}" />
-      </a>
-    </div>
-  </th:block>
+  <div th:replace="fragments/source::source(source=${model.source}, url=${model.study.url}, entityType='study')"></div>
 
   <div th:replace="fragments/row::text-row(label='Project name', text=${model.study.programName})"></div>
   <div th:replace="fragments/row::text-row(label='Description', text=${model.study.studyDescription})"></div>
-- 
GitLab


From 951b746fe76e342e912269cb6895539c5f1296e3 Mon Sep 17 00:00:00 2001
From: cexbrayat <cedric@ninja-squad.com>
Date: Wed, 1 Sep 2021 16:25:20 +0200
Subject: [PATCH 2/2] fix: typos and missing alt attribute

---
 backend/src/main/resources/templates/fragments/institute.html | 2 +-
 backend/src/main/resources/templates/germplasm.html           | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/backend/src/main/resources/templates/fragments/institute.html b/backend/src/main/resources/templates/fragments/institute.html
index 0efa76dd..7e603095 100644
--- a/backend/src/main/resources/templates/fragments/institute.html
+++ b/backend/src/main/resources/templates/fragments/institute.html
@@ -11,7 +11,7 @@ Its unique argument (institute) is an InstituteVO
 
 <th:block th:fragment="institute(institute)">
   <div class="text-center py-2" th:if="${institute.logo}">
-    <img th:src="${institute.logo}" />
+    <img th:src="${institute.logo}" th:alt="${institute.instituteName}"/>
   </div>
   <div th:replace="fragments/row::text-row(label='Code', text=${institute.instituteCode})"></div>
   <div th:replace="fragments/row::text-row(label='Acronym', text=${institute.acronym})"></div>
diff --git a/backend/src/main/resources/templates/germplasm.html b/backend/src/main/resources/templates/germplasm.html
index 840b0d44..1c51d438 100644
--- a/backend/src/main/resources/templates/germplasm.html
+++ b/backend/src/main/resources/templates/germplasm.html
@@ -112,7 +112,7 @@
                 </div>
               </th:block>
               <th:block th:unless="${#lists.isEmpty(model.germplasm.taxonSynonyms)}">
-                <div th:replace="fragments/row::row(label='Taxon common names', content=~{::#taxon-synonyms})">
+                <div th:replace="fragments/row::row(label='Taxon synonyms', content=~{::#taxon-synonyms})">
                   <div id="taxon-synonyms" class="content-overflow" th:text="${#strings.listJoin(model.germplasm.taxonSynonyms, ', ')}"></div>
                 </div>
               </th:block>
-- 
GitLab