From d49ad5671770b4095fd3bd54991a87bb277a6ac8 Mon Sep 17 00:00:00 2001
From: Patrick Barroca <pbarroca@afi-sa.fr>
Date: Wed, 18 Sep 2019 19:39:39 +0200
Subject: [PATCH] dev #93671 : custom search on authorities

---
 VERSIONS_WIP/93671                            |   1 +
 .../controllers/AuthoritySearchController.php |  25 +-
 .../opac/controllers/RechercheController.php  |  12 +-
 .../scripts/authority-search/index.phtml      |   2 +-
 library/Class/CriteresRecherche.php           |  22 +-
 library/Class/CriteresRecherche/Abstract.php  |   7 +
 .../CriteresRecherche/AuthoritiesParam.php    | 297 ++++++++++++++++++
 library/Class/CriteresRecherche/Authority.php |   1 +
 library/Class/MoteurRecherche.php             |   8 +
 library/Class/Notice.php                      |  21 ++
 library/Class/Notice/AuthorityPartial.php     |   4 +-
 library/Class/Notice/AuthorityRelation.php    |   6 +
 library/Class/Notice/Facette.php              |  12 +
 library/Class/SearchForm.php                  |  43 ++-
 library/Trait/SearchCriteriaVisitor.php       |   2 +
 library/ZendAfi/Form/AuthoritySearch.php      |   2 +-
 .../Form/Decorator/AuthorityPicker.php        |  78 +++++
 library/ZendAfi/Form/Element/Authority.php    |  72 +++++
 .../View/Helper/AuthoritySearch/Record.php    |  26 +-
 .../AuthoritySearch/RecordRelations.php       |  26 +-
 .../Helper/AuthoritySearch/RecordUsages.php   |  11 +-
 .../View/Helper/AuthoritySearch/Result.php    |   8 +-
 .../View/Helper/TagCriteresRecherche.php      |  10 +-
 public/opac/css/global.css                    |   7 +
 public/opac/js/authority-search/style.css     | 122 +++++++
 .../js/authority_picker/authority_picker.js   |  60 ++++
 public/opac/js/authority_picker/tests.html    |  42 +++
 public/opac/js/authority_picker/tests.js      | 100 ++++++
 .../controllers/RechercheControllerTest.php   | 118 +++++++
 .../AdvancedSearch/AdvancedSearchTest.php     | 100 +++++-
 .../scenarios/Authorities/AuthoritiesTest.php |  17 +
 .../SearchResult/SearchResultTest.php         |  63 +++-
 32 files changed, 1251 insertions(+), 74 deletions(-)
 create mode 100644 VERSIONS_WIP/93671
 create mode 100644 library/Class/CriteresRecherche/AuthoritiesParam.php
 create mode 100644 library/ZendAfi/Form/Decorator/AuthorityPicker.php
 create mode 100644 library/ZendAfi/Form/Element/Authority.php
 create mode 100644 public/opac/js/authority-search/style.css
 create mode 100644 public/opac/js/authority_picker/authority_picker.js
 create mode 100644 public/opac/js/authority_picker/tests.html
 create mode 100644 public/opac/js/authority_picker/tests.js

diff --git a/VERSIONS_WIP/93671 b/VERSIONS_WIP/93671
new file mode 100644
index 00000000000..b391b24b5a3
--- /dev/null
+++ b/VERSIONS_WIP/93671
@@ -0,0 +1 @@
+ - ticket #93671 : Recherche avancée : Ajout de la possibilité de chercher par autorité hiérarchique
\ No newline at end of file
diff --git a/application/modules/opac/controllers/AuthoritySearchController.php b/application/modules/opac/controllers/AuthoritySearchController.php
index 166e9c2c829..0237848e24e 100644
--- a/application/modules/opac/controllers/AuthoritySearchController.php
+++ b/application/modules/opac/controllers/AuthoritySearchController.php
@@ -24,16 +24,15 @@ class AuthoritySearchController extends ZendAfi_Controller_Action {
   public function indexAction() {
     $criteres = (new Class_CriteresRecherche_Authority)->setParams($this->_request->getParams());
 
-    if ($this->_request->isPost()) {
-      $redirect_params = $criteres->getUrlCriteres();
-      return $this->_redirect($this->view->url($redirect_params, null, true),
-                              ['prependBase' => false]);
-    }
+    if ($this->_request->isPost())
+      return $this->_handlePost($criteres);
 
     $prefs = $this->_getActionPreferences();
 
     $this->view->titre = $prefs['titre'];
     $this->view->tree_roots = $criteres->getParam('tree_roots');
+    $this->view->select_for = $criteres->getParam('select_for');
+
     $action_params = $criteres->getUrlWithoutExpression();
     $this->view->form = (new ZendAfi_Form_AuthoritySearch())
       ->setAction($this->view->url($action_params, null, true));
@@ -67,4 +66,20 @@ class AuthoritySearchController extends ZendAfi_Controller_Action {
 
     return $this->view->record = $record;
   }
+
+
+  protected function _handlePost($criteres) {
+    $redirect_params = $criteres->getUrlCriteres();
+    if ($this->isPopupRequest())
+      $redirect_params['render'] = 'popup';
+
+    return $this->_redirect($this->view->url($redirect_params, null, true),
+                            ['prependBase' => false]);
+  }
+
+
+  protected function _redirect($url, array $options = array()) {
+    // always classical redirect even in popup
+    $this->_helper->redirector->gotoUrl($url, $options);
+  }
 }
diff --git a/application/modules/opac/controllers/RechercheController.php b/application/modules/opac/controllers/RechercheController.php
index 1b5d0343d82..c37f6b50044 100644
--- a/application/modules/opac/controllers/RechercheController.php
+++ b/application/modules/opac/controllers/RechercheController.php
@@ -95,23 +95,25 @@ class RechercheController extends ZendAfi_Controller_Action {
       unset($params['q']);
     }
 
+    $criteres_recherche = $this->newCriteresRecherches($params);
+
     if ($multifacets = array_merge($this->_extractDynamicFacets($params),
                                    $this->_extractMultifacetsPost())) {
-      $url = $this->newCriteresRecherches($params)
-                  ->getUrlWithMultifacetsUpdate($multifacets);
+      $url = $criteres_recherche->getUrlWithMultifacetsUpdate($multifacets);
 
       isset($params ['titre']) ? ($url ['titre'] = $params ['titre']) : '';
 
       return $this->_redirect($this->view->url($url, null, true),
-                       ['prependBase' => false]);
+                              ['prependBase' => false]);
     }
 
-    $criteres_recherche = $this->newCriteresRecherches($params);
-
     if ($this->view->statut == 'guidee')
       $criteres_recherche->updateRubrique('guidee');
 
     if ($this->_request->isPost()) {
+      $criteres_recherche = (new Class_CriteresRecherche_AuthoritiesParam($params))
+        ->injectInto($criteres_recherche);
+
       $criteria_params = $criteres_recherche->getUrlCriteresWithFacettes();
       // preserve module as we may come from Telephone_RechercheController
       $criteria_params['module'] = $this->_request->getModuleName();
diff --git a/application/modules/opac/views/scripts/authority-search/index.phtml b/application/modules/opac/views/scripts/authority-search/index.phtml
index 5d1459f3ed3..93c27770726 100644
--- a/application/modules/opac/views/scripts/authority-search/index.phtml
+++ b/application/modules/opac/views/scripts/authority-search/index.phtml
@@ -2,5 +2,5 @@
 $this->openBoite($this->titre);
 echo $this->renderForm($this->form);
 echo $this->AuthoritySearch_Header($this->search_result);
-echo $this->AuthoritySearch_Result($this->search_result, $this->record, $this->tree_roots);
+echo $this->AuthoritySearch_Result($this->search_result, $this->record, $this->tree_roots, $this->select_for);
 $this->closeBoite();
diff --git a/library/Class/CriteresRecherche.php b/library/Class/CriteresRecherche.php
index 4338012f610..1ac68048874 100644
--- a/library/Class/CriteresRecherche.php
+++ b/library/Class/CriteresRecherche.php
@@ -94,7 +94,8 @@ class Class_CriteresRecherche extends Class_CriteresRecherche_Abstract {
                         'bib_select',
                         'serie',
                         'from',
-                        'selection']);
+                        'selection',
+                        'authorities']);
   }
 
 
@@ -158,6 +159,12 @@ class Class_CriteresRecherche extends Class_CriteresRecherche_Abstract {
   }
 
 
+  public function getAuthorities() {
+    return (new Class_CriteresRecherche_AuthoritiesParam)
+      ->fromParamString($this->getParam('authorities'));
+  }
+
+
   public function hasEmptyDomain() {
     if( !$catalogue = Class_Catalogue::find($this->getParam('id_catalogue')))
       return false;
@@ -521,6 +528,8 @@ class Class_CriteresRecherche extends Class_CriteresRecherche_Abstract {
     foreach($this->getMultiFacets() as $facet)
       $visitor->visitMultiFacet($facet);
 
+    $this->getAuthorities()->acceptVisitor($visitor);
+
     $filtres = $this->getFiltres();
     foreach($filtres as $filtre)
       $visitor->visitFiltre($filtre);
@@ -792,6 +801,17 @@ class Class_CriteresRecherche extends Class_CriteresRecherche_Abstract {
   }
 
 
+  public function getUrlRemoveAuthority($authority) {
+    $url = $this->getUrlRetourListe();
+    $url['authorities'] = $this->getAuthorities()
+                               ->without($authority)
+                               ->asParamString();
+    $url['page'] = null;
+    $url['genre'] = null;
+    return array_filter($url);
+  }
+
+
   public function getUrlWithMultifacetsUpdate($update) {
     $url = $this->getUrlRetourListe();
     $multifacets = isset($url['multifacets']) ? explode('-', $url['multifacets']) : [];
diff --git a/library/Class/CriteresRecherche/Abstract.php b/library/Class/CriteresRecherche/Abstract.php
index 382255cc9dd..b46d14d40e7 100644
--- a/library/Class/CriteresRecherche/Abstract.php
+++ b/library/Class/CriteresRecherche/Abstract.php
@@ -77,6 +77,13 @@ abstract class Class_CriteresRecherche_Abstract {
   }
 
 
+  public function setParam($name, $value) {
+    $this->_params = array_merge($this->_params,
+                                 $this->filterParams([$name => $value]));
+    return $this;
+  }
+
+
   public function getAvailablePageSize() {
     $profil_param = (new Class_Profil_Preferences_SearchResult())->getPageSize($this->_profil);
     $options = array_unique(['10' => 10,
diff --git a/library/Class/CriteresRecherche/AuthoritiesParam.php b/library/Class/CriteresRecherche/AuthoritiesParam.php
new file mode 100644
index 00000000000..bde7be0f070
--- /dev/null
+++ b/library/Class/CriteresRecherche/AuthoritiesParam.php
@@ -0,0 +1,297 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class Class_CriteresRecherche_AuthoritiesParam {
+  const
+    NAME_PREFIX = 'authority_',
+    MODE_PREFIX = 'mode_',
+    PARAM_SEPARATOR = '-';
+
+  protected
+    $_authorities,
+    $_params;
+
+  public function __construct($params=[]) {
+    $this->_authorities = new Storm_Collection;
+    $this->_params = $params;
+
+    foreach($params as $k => $v)
+      $this->_importFrom($k, $v, $params);
+
+    $this->_params = null;
+  }
+
+
+  public function acceptVisitor($visitor) {
+    $this->_authorities
+      ->eachDo(function($each) use($visitor)
+               {
+                 $visitor->visitAuthority($each);
+               });
+  }
+
+
+  public function fromParamString($value) {
+    foreach(explode(static::PARAM_SEPARATOR, $value) as $part)
+      $this->_addFromParamString($part);
+
+    return $this;
+  }
+
+
+  protected function _addFromParamString($value) {
+    if ($one = Class_CriteresRecherche_AuthorityParam::fromParamString($value))
+      $this->_authorities->append($one);
+
+    return $this;
+  }
+
+
+  /**
+   * @param $criteres Class_CriteresRecherche_Abstract
+   * @return Class_CriteresRecherche_Abstract
+   */
+  public function injectInto($criteres) {
+    return $this->_authorities->isEmpty()
+      ? $criteres
+      : $criteres->setParam('authorities', $this->asParamString());
+  }
+
+
+  public function asParamString() {
+    $authorities = $this->_authorities
+      ->collect(function($each) { return $each->asString(); });
+
+    return implode(static::PARAM_SEPARATOR, $authorities->getArrayCopy());
+  }
+
+
+  public function without($authority) {
+    $this->_authorities = $this->_authorities
+      ->reject(function($each) use($authority)
+               {
+                 return $authority->asString() == $each->asString();
+               });
+
+    return $this;
+  }
+
+
+  protected function _importFrom($name, $value, $context) {
+    if (!$this->_isAuthorityParam($name)
+        || (!$record = Class_Notice::find($value))
+        || (!$record->isAuthority()))
+      return;
+
+    foreach($record->getDynamicFacetRoots() as $root) {
+      $this->_authorities
+        ->append($this->_newParam($root, $record->getId(), $this->_modeOf($name)));
+    }
+  }
+
+
+  protected function _modeOf($name) {
+    return $this->_getParam(static::MODE_PREFIX  . $name);
+  }
+
+
+  protected function _getParam($name, $default=null) {
+    return array_key_exists($name, $this->_params)
+      ? $this->_params[$name]
+      : $default;
+  }
+
+
+  protected function _newParam($facet, $id, $mode) {
+    return Class_CriteresRecherche_AuthorityParam::newWith($facet, $id, $mode);
+  }
+
+
+  protected function _isAuthorityParam($name) {
+    return static::NAME_PREFIX === substr($name, 0, strlen(static::NAME_PREFIX));
+  }
+}
+
+
+
+
+abstract class Class_CriteresRecherche_AuthorityParam {
+  use Trait_Translator;
+
+  const
+    PART_SEPARATOR = '_',
+    HIERARCHY_NONE = '0',
+    HIERARCHY_ALL = '1',
+    DUMMY_FACET = 'H0000';
+
+  protected
+    $_facet,
+    $_id,
+    $_mode;
+
+
+  public static function fromParamString($value) {
+    $parts = explode(static::PART_SEPARATOR, $value);
+    return (3 === count($parts))
+      ? static::newWith($parts[0], $parts[1], $parts[2])
+      : null;
+  }
+
+
+  public static function newWith($facet, $id, $mode) {
+    if (null === $mode)
+      $mode = static::HIERARCHY_ALL;
+
+    return static::HIERARCHY_ALL === $mode
+      ? new Class_CriteresRecherche_AuthorityParam_Hierarchical($facet, $id)
+      : new Class_CriteresRecherche_AuthorityParam_Flat($facet, $id);
+  }
+
+
+  public function __construct($facet, $id) {
+    $this->_facet = $facet;
+    $this->_id = $id;
+  }
+
+
+  /** @param $engine Class_MoteurRecherche */
+  abstract public function applyTo($engine);
+
+
+  public function getTypeLabel() {
+    return ($thesaurus = Class_CodifThesaurus::findFirstBy(['id_thesaurus' => substr($this->_facet, 1)]))
+      ? $thesaurus->getLibelleFacette()
+      : $this->_('Autorité');
+  }
+
+
+  public function getLabel() {
+    $label = ($record = $this->_record())
+      ? $record->getTitrePrincipal()
+      : $this->_('Inconnu');
+
+    return $label . $this->_specificsLabel();
+  }
+
+
+  abstract protected function _specificsLabel();
+
+
+  protected function _applyDummyFacetTo($engine) {
+    $this->_applyFacetsTo([static::DUMMY_FACET], $engine);
+  }
+
+
+  protected function _applyFacetsTo($facets, $engine) {
+    $engine
+      ->setCondition('MATCH(facettes) AGAINST(\'+(' . implode(' ', $facets) . ')\' IN BOOLEAN MODE)');
+  }
+
+
+  public function _record() {
+    return (($record = Class_Notice::find($this->_id))
+            && $record->isAuthority())
+      ? $record
+      : null;
+  }
+
+
+  public function asString() {
+    return implode(static::PART_SEPARATOR,
+                   [$this->_facet, $this->_id, $this->_mode]);
+  }
+}
+
+
+
+
+class Class_CriteresRecherche_AuthorityParam_Flat
+  extends Class_CriteresRecherche_AuthorityParam {
+
+  public function __construct($facet, $id) {
+    parent::__construct($facet, $id);
+    $this->_mode = static::HIERARCHY_NONE;
+  }
+
+
+  protected function _specificsLabel() {
+    return '';
+  }
+
+
+  /** @param $engine Class_MoteurRecherche */
+  public function applyTo($engine) {
+    if (!$record = $this->_record())
+      return;
+
+    ($facets = $record->getDynamicFacetValues())
+      ? $this->_applyFacetsTo($facets, $engine)
+      : $this->_applyDummyFacetTo($engine);
+  }
+}
+
+
+
+
+class Class_CriteresRecherche_AuthorityParam_Hierarchical
+  extends Class_CriteresRecherche_AuthorityParam {
+
+  public function __construct($facet, $id) {
+    parent::__construct($facet, $id);
+    $this->_mode = static::HIERARCHY_ALL;
+  }
+
+
+  protected function _specificsLabel() {
+    return $this->_(' (termes spécifiques inclus)');
+  }
+
+
+  /** @param $engine Class_MoteurRecherche */
+  public function applyTo($engine) {
+    if (!$record = $this->_record())
+      return;
+
+    $facets = $record->getDynamicFacetValues();
+    $this->_collectRecursiveFacetsFrom($record, $facets);
+
+    $facets
+      ? $this->_applyFacetsTo($facets, $engine)
+      : $this->_applyDummyFacetTo($engine);
+  }
+
+
+  protected function _collectRecursiveFacetsFrom($record, &$facets) {
+    Class_Notice_AuthorityRelations::allFor($record)
+      ->specifics()
+      ->eachDo(function($relation) use(&$facets)
+               {
+                 if (!$record = $relation->getRecord())
+                   return;
+
+                 foreach($record->getDynamicFacetValues() as $facet)
+                   $facets[] = $facet;
+
+                 $this->_collectRecursiveFacetsFrom($record, $facets);
+               });
+  }
+}
\ No newline at end of file
diff --git a/library/Class/CriteresRecherche/Authority.php b/library/Class/CriteresRecherche/Authority.php
index c24cef1e66a..7bd8fb2ae07 100644
--- a/library/Class/CriteresRecherche/Authority.php
+++ b/library/Class/CriteresRecherche/Authority.php
@@ -25,6 +25,7 @@ class Class_CriteresRecherche_Authority extends Class_CriteresRecherche_Abstract
     $parameters = parent::getValidParameters();
     $parameters[] = 'expressionRecherche';
     $parameters[] = 'tree_roots';
+    $parameters[] = 'select_for';
     return $parameters;
   }
 
diff --git a/library/Class/MoteurRecherche.php b/library/Class/MoteurRecherche.php
index 603d397f7df..068227e38fd 100644
--- a/library/Class/MoteurRecherche.php
+++ b/library/Class/MoteurRecherche.php
@@ -723,6 +723,14 @@ class Class_MoteurRecherche {
   }
 
 
+  /**
+   * @param $authority Class_CriteresRecherche_AuthorityParam
+   */
+  public function visitAuthority($authority) {
+    $authority->applyTo($this);
+  }
+
+
   public function setTypeCondition($type) {
     if (in_array($type, [Class_Notice::TYPE_BIBLIOGRAPHIC,
                          Class_Notice::TYPE_AUTHORITY]))
diff --git a/library/Class/Notice.php b/library/Class/Notice.php
index dff85e46d3b..0bf3cb620da 100644
--- a/library/Class/Notice.php
+++ b/library/Class/Notice.php
@@ -1879,4 +1879,25 @@ class Class_Notice extends Storm_Model_Abstract {
   public function getFileContentFirstWords() {
     return substr($this->getFileContent(), 0, 180) . '…';
   }
+
+
+  public function getDynamicFacetValues() {
+    return (new Storm_Collection(Class_Notice_Facette::parseFacettesFromNoticeField($this->getFacettes())))
+      ->select(function($facet) { return $facet->isDynamicFacet(); })
+      ->collect(function($facet) { return $facet->getCle(); })
+      ->getArrayCopy();
+  }
+
+
+  public function getDynamicFacetRoots() {
+    return (new Storm_Collection(Class_Notice_Facette::parseFacettesFromNoticeField($this->getFacettes())))
+      ->select(function($facet) { return $facet->isDynamicFacetRoot(); })
+      ->collect(function($facet) { return $facet->getCle(); })
+      ->getArrayCopy();
+  }
+
+
+  public function isAuthority() {
+    return static::TYPE_AUTHORITY === $this->getType();
+  }
 }
\ No newline at end of file
diff --git a/library/Class/Notice/AuthorityPartial.php b/library/Class/Notice/AuthorityPartial.php
index 75a70bf9861..57b3f3cc3c4 100644
--- a/library/Class/Notice/AuthorityPartial.php
+++ b/library/Class/Notice/AuthorityPartial.php
@@ -26,13 +26,13 @@ class Class_Notice_AuthorityPartial {
   const DEFAULT_AGENCY = 'Bokeh';
 
   public function newWith($type, $id, $heading, $rules) {
-    $genaral_info = $this->getTimeSource()->dateFormat('Ymd') . 'afrey50      ba0';
+    $general_info = $this->getTimeSource()->dateFormat('Ymd') . 'afrey50      ba0';
 
     return (new Class_NoticeUnimarc_Fluent())
       ->beAuthority()
       ->type($type)
       ->zoneWithContent('001', $id)
-      ->zoneWithChildren('100', ['a' => $genaral_info])
+      ->zoneWithChildren('100', ['a' => $general_info])
       ->zoneWithChildren('152', $rules)
       ->zoneWithChildren((new Class_Notice_AuthorityType)->zoneForType('2', $type),
                          ['a' => $heading])
diff --git a/library/Class/Notice/AuthorityRelation.php b/library/Class/Notice/AuthorityRelation.php
index 03e06fb0d50..3bcae857e0f 100644
--- a/library/Class/Notice/AuthorityRelation.php
+++ b/library/Class/Notice/AuthorityRelation.php
@@ -140,4 +140,10 @@ class Class_Notice_AuthorityRelation {
   public function isOther() {
     return static::TYPE_OTHER === $this->_type;
   }
+
+
+  public function getRecord() {
+    if ($item = Class_Exemplaire::findFirstAuthorityItemByOriginId($this->_id))
+      return $item->getNotice();
+  }
 }
diff --git a/library/Class/Notice/Facette.php b/library/Class/Notice/Facette.php
index ab1a48e33d3..33e67e940f5 100644
--- a/library/Class/Notice/Facette.php
+++ b/library/Class/Notice/Facette.php
@@ -126,4 +126,16 @@ class Class_Notice_Facette {
   public function isAuthor() {
     return Class_CodifAuteur::CODE_FACETTE == $this->getGroupCodeFromKey();
   }
+
+
+  public function isDynamicFacet() {
+    return Class_CodifThesaurus::CODE_FACETTE === substr($this->_cle, 0, 1)
+      && strlen($this->getValue()) === (Class_CodifThesaurus::ID_KEY_LENGTH * 2);
+  }
+
+
+  public function isDynamicFacetRoot() {
+    return Class_CodifThesaurus::CODE_FACETTE === substr($this->_cle, 0, 1)
+      && strlen($this->getValue()) === Class_CodifThesaurus::ID_KEY_LENGTH;
+  }
 }
\ No newline at end of file
diff --git a/library/Class/SearchForm.php b/library/Class/SearchForm.php
index 4f85bcaf2eb..b349e755a18 100644
--- a/library/Class/SearchForm.php
+++ b/library/Class/SearchForm.php
@@ -45,10 +45,13 @@ class Class_SearchFormLoader extends Storm_Model_Loader {
 }
 
 
+
 class Class_SearchForm extends Storm_Model_Abstract {
   use Trait_TimeSource;
 
-  protected static $_includer;
+  protected static
+    $_includer,
+    $_throw_errors = false;
 
   protected
     $_table_name = 'search_form',
@@ -66,6 +69,12 @@ class Class_SearchForm extends Storm_Model_Abstract {
   }
 
 
+  /** @category testing */
+  public static function throwErrors($flag) {
+    static::$_throw_errors = $flag;
+  }
+
+
   public static function getIncluder() {
     return static::$_includer
       ? static::$_includer
@@ -90,7 +99,7 @@ class Class_SearchForm extends Storm_Model_Abstract {
 
 
   public function getFormInstance() {
-    return (new Class_SearchFormWrapper($this))->getFormInstance();
+    return (new Class_SearchFormWrapper($this, static::$_throw_errors))->getFormInstance();
   }
 }
 
@@ -104,12 +113,14 @@ class Class_SearchFormWrapper {
 
   protected
     $_search_form,
+    $_throw_errors = false,
     $_form,
     $_errors = [];
 
 
-  public function __construct($search_form) {
+  public function __construct($search_form, $throw_errors) {
     $this->_search_form = $search_form;
+    $this->_throw_errors = $throw_errors;
   }
 
 
@@ -128,15 +139,8 @@ class Class_SearchFormWrapper {
     if (!$validator->isValid($content))
       return $this->addError($this->_('Le fichier lié à ce formulaire n\'est pas valide : %s', $validator->getError()));
 
-    $includer = Class_SearchForm::getIncluder();
     $form = new ZendAfi_Form_AdvancedSearch();
-
-    $runtime_error = '';
-    try {
-      $includer($file->getRealpath(), $form);
-    } catch(Exception $e) {
-      $runtime_error = $e->getMessage();
-    }
+    $runtime_error = $this->_include($file->getRealpath(), $form);
 
     if ($runtime_error)
       return $this->addError($this->_('Le fichier lié à ce formulaire a provoqué une erreur d\'exécution : %s',
@@ -146,6 +150,23 @@ class Class_SearchFormWrapper {
   }
 
 
+  protected function _include($path, $form) {
+    $includer = Class_SearchForm::getIncluder();
+
+    if ($this->_throw_errors) {
+      $includer($path, $form);
+      return '';
+    }
+
+    try {
+      $includer($path, $form);
+      return '';
+    } catch(Exception $e) {
+      return $e->getMessage();
+    }
+  }
+
+
   public function hasError() {
     return !empty($this->_errors);
   }
diff --git a/library/Trait/SearchCriteriaVisitor.php b/library/Trait/SearchCriteriaVisitor.php
index 71478b94c17..e3a02c21fb2 100644
--- a/library/Trait/SearchCriteriaVisitor.php
+++ b/library/Trait/SearchCriteriaVisitor.php
@@ -57,4 +57,6 @@ trait Trait_SearchCriteriaVisitor {
   public function visitBookmarkedSearch($bookmark, $version) {}
 
   public function visitRecordType($type) {}
+
+  public function visitAuthority($authority) {}
 }
\ No newline at end of file
diff --git a/library/ZendAfi/Form/AuthoritySearch.php b/library/ZendAfi/Form/AuthoritySearch.php
index 59366814ef6..e40aa6a2e96 100644
--- a/library/ZendAfi/Form/AuthoritySearch.php
+++ b/library/ZendAfi/Form/AuthoritySearch.php
@@ -24,7 +24,7 @@ class ZendAfi_Form_AuthoritySearch extends ZendAfi_Form {
   public function init() {
     parent::init();
     $this
-      ->addElement('text', 'expressionRecherche', [])
+      ->addElement('text', 'expressionRecherche')
       ->addElement('submit', 'run', ['label' => $this->_('Rechercher')]);
   }
 }
diff --git a/library/ZendAfi/Form/Decorator/AuthorityPicker.php b/library/ZendAfi/Form/Decorator/AuthorityPicker.php
new file mode 100644
index 00000000000..045163186e0
--- /dev/null
+++ b/library/ZendAfi/Form/Decorator/AuthorityPicker.php
@@ -0,0 +1,78 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_Form_Decorator_AuthorityPicker extends Zend_Form_Decorator_Abstract {
+  use Trait_Translator;
+  const NAME_PREFIX = 'authority_';
+  const MODE_PREFIX = 'mode_authority_';
+
+  public function render($content) {
+    $view = $this->_element->getView();
+    $container_id = 'authority_picker_' . $this->_element->getId();
+    $input_id = static::NAME_PREFIX . $this->_element->getName();
+    $mode_id = static::MODE_PREFIX . $this->_element->getName();
+
+    Class_ScriptLoader::getInstance()
+      ->addOPACScript('authority_picker/authority_picker.js')
+      ->addJQueryReady('$("#' . $container_id .'").authority_picker({
+name:' . json_encode($input_id) . ',
+pick_url:' . json_encode($view->url($this->_getParams(), null, true)) .'
+})');
+
+    return $content
+      . $view->tag('div',
+                   $view->formHidden($input_id, $this->_element->getValue())
+
+                   . $view->tag('span', $this->_element->getRecordLabel())
+
+                   . $view->button_New((new Class_Entity)
+                                       ->setText($this->_element->getPickButtonLabel())
+                                       ->setAttribs(['onclick' => 'return false;']))
+
+                   . $view->button_Cancel((new Class_Entity)
+                                          ->setText($this->_element->getResetButtonLabel())
+                                          ->setAttribs(['onclick' => 'return false;']))
+
+                   . $view->formLabel($mode_id,
+                                      $view->formCheckbox($mode_id, 1, ['checked' => true])
+                                      . ' '
+                                      . $this->_('Inclure les termes spécifiques'),
+                                      ['escape' => false])
+                   ,
+                   ['id' => $container_id]);
+  }
+
+
+  protected function _getParams() {
+    $params = ['controller' => 'authority-search',
+               'action' => 'index',
+               'select_for' => $this->_element->getId()];
+
+    if ($facets = $this->_element->getAttrib('facets'))
+      $params['facettes'] = $facets;
+
+    if ($tree_roots = $this->_element->getAttrib('tree_roots'))
+      $params['tree_roots'] = $tree_roots;
+
+    return $params;
+  }
+}
diff --git a/library/ZendAfi/Form/Element/Authority.php b/library/ZendAfi/Form/Element/Authority.php
new file mode 100644
index 00000000000..3042bee37ad
--- /dev/null
+++ b/library/ZendAfi/Form/Element/Authority.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * Copyright (c) 2012-2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+
+class ZendAfi_Form_Element_Authority extends Zend_Form_Element_Xhtml {
+  use Trait_Translator;
+
+  protected
+    $_pick_button_label,
+    $_reset_button_label;
+
+  public function __construct($spec, $options = null) {
+    parent::__construct($spec, $options);
+
+    $decorators = $this->_decorators;
+    $this->_decorators = ['AuthorityPicker' => new ZendAfi_Form_Decorator_AuthorityPicker()];
+
+    foreach ($decorators as $name => $value)
+      $this->_decorators[$name] = $value;
+
+    $this->removeDecorator('ViewHelper');
+  }
+
+
+  public function getRecordLabel() {
+    return ($id = $this->getValue()) && ($record = Class_Notice::find($id))
+      ? $record->getTitrePrincipal()
+      : $this->_('non sélectionné');
+  }
+
+
+  public function setPickButtonLabel($label) {
+    $this->_pick_button_label = $label;
+  }
+
+
+  public function getPickButtonLabel() {
+    return $this->_pick_button_label
+      ? $this->_pick_button_label
+      : $this->_('Choisir');
+  }
+
+
+  public function setResetButtonLabel($label) {
+    $this->_reset_button_label = $label;
+  }
+
+
+  public function getResetButtonLabel() {
+    return $this->_reset_button_label
+      ? $this->_reset_button_label
+      : $this->_('Annuler');
+  }
+}
diff --git a/library/ZendAfi/View/Helper/AuthoritySearch/Record.php b/library/ZendAfi/View/Helper/AuthoritySearch/Record.php
index 109328a27d5..892b15464a9 100644
--- a/library/ZendAfi/View/Helper/AuthoritySearch/Record.php
+++ b/library/ZendAfi/View/Helper/AuthoritySearch/Record.php
@@ -21,15 +21,35 @@
 
 
 class ZendAfi_View_Helper_AuthoritySearch_Record extends ZendAfi_View_Helper_BaseHelper {
-  public function AuthoritySearch_Record($record) {
+  public function AuthoritySearch_Record($record, $select_for) {
     if (!$record)
       return '';
 
+    Class_ScriptLoader::getInstance()->addOPACScriptStyleSheet('authority-search/style.css');
+
     return
-      $this->_tag('h2', $record->getTitrePrincipal())
-      . $this->view->AuthoritySearch_RecordRelations($record)
+      $this->_tag('div',
+                  $this->_renderLabel($record, $select_for)
+                  . $this->view->AuthoritySearch_RecordRelations($record),
+                  ['class' => 'grid-wrapper'])
       . $this->view->AuthoritySearch_RecordNotes($record)
       . $this->view->AuthoritySearch_RecordUsages($record)
       ;
   }
+
+
+  protected function _renderLabel($record, $select_for) {
+    $label = $record->getTitrePrincipal();
+    if ($select_for) {
+      $label .=  ' ' . $this->view
+        ->button((new Class_Entity)
+                 ->setText($this->_('Choisir'))
+                 ->setAttribs(['onclick' => 'return false;',
+                               'data-record' => $record->getId(),
+                               'data-label' => $label,
+                               'class' => 'authority_pick']));
+    }
+
+    return $this->_tag('div', $this->_tag('h2', $label), ['class' => 'term']);
+  }
 }
\ No newline at end of file
diff --git a/library/ZendAfi/View/Helper/AuthoritySearch/RecordRelations.php b/library/ZendAfi/View/Helper/AuthoritySearch/RecordRelations.php
index 7b0c8bb9eae..828a710e75c 100644
--- a/library/ZendAfi/View/Helper/AuthoritySearch/RecordRelations.php
+++ b/library/ZendAfi/View/Helper/AuthoritySearch/RecordRelations.php
@@ -31,18 +31,18 @@ class ZendAfi_View_Helper_AuthoritySearch_RecordRelations extends ZendAfi_View_H
 
     $relations = Class_Notice_AuthorityRelations::allFor($record);
     $parts = [];
-    foreach([$this->_('Terme générique') => $relations->generics(),
-             $this->_('Terme spécifique') => $relations->specifics(),
-             $this->_('Terme rejeté') => $relations->rejects(),
-             $this->_('Terme associé') => $relations->links()]
-            as $label => $relations)
-      $parts[] = $this->_renderLabelledRelations($label, $relations);
+    foreach([[$this->_('Terme générique'), $relations->generics(), 'generic', 'north'],
+             [$this->_('Terme spécifique'), $relations->specifics(), 'specific', 'south'],
+             [$this->_('Terme rejeté'), $relations->rejects(), 'reject', 'west'],
+             [$this->_('Terme associé'), $relations->links(), 'link', 'east']]
+            as $params)
+      $parts[] = call_user_func_array([$this, '_renderLabelledRelations'], $params);
 
     return implode(array_filter($parts));
   }
 
 
-  protected function _renderLabelledRelations($label, $relations) {
+  protected function _renderLabelledRelations($label, $relations, $class, $direction) {
     if ($relations->isEmpty())
       return '';
 
@@ -53,8 +53,16 @@ class ZendAfi_View_Helper_AuthoritySearch_RecordRelations extends ZendAfi_View_H
                 });
 
     return
-      $this->_tag('h3', $label)
-      . $this->_tag('ul', implode($items->getArrayCopy()));
+      $this->_tag('div',
+                  $this->_tag('h3', $label)
+                  . $this->_tag('ul', implode($items->getArrayCopy())),
+                  ['class' => $class])
+      . $this->_renderArrow($direction);
+  }
+
+
+  protected function _renderArrow($direction) {
+    return $this->_tag('div', $this->_tag('i', ''), ['class' => 'arrow ' . $direction]);
   }
 
 
diff --git a/library/ZendAfi/View/Helper/AuthoritySearch/RecordUsages.php b/library/ZendAfi/View/Helper/AuthoritySearch/RecordUsages.php
index b459f158f03..86a590c8895 100644
--- a/library/ZendAfi/View/Helper/AuthoritySearch/RecordUsages.php
+++ b/library/ZendAfi/View/Helper/AuthoritySearch/RecordUsages.php
@@ -30,10 +30,7 @@ class ZendAfi_View_Helper_AuthoritySearch_RecordUsages extends ZendAfi_View_Help
 
 
   protected function _renderUsage($record) {
-    $facets = array_filter($record->getFacetCodes(),
-                           function($facet) { return $this->_isDynamicFacetValue($facet); });
-
-    if (!$facets)
+    if (!$facets = $record->getDynamicFacetValues())
       return $this->_('Utilisé dans aucune notice');
 
     $criterias = (new Class_CriteresRecherche())
@@ -51,12 +48,6 @@ class ZendAfi_View_Helper_AuthoritySearch_RecordUsages extends ZendAfi_View_Help
   }
 
 
-  protected function _isDynamicFacetValue($facet) {
-    return Class_CodifThesaurus::CODE_FACETTE === substr($facet, 0, 1)
-      && strlen($facet) === (Class_CodifThesaurus::ID_KEY_LENGTH * 2) + 1;
-  }
-
-
   protected function _countByCriterias($criterias) {
     return (new Class_MoteurRecherche)
       ->beNotExtensible()
diff --git a/library/ZendAfi/View/Helper/AuthoritySearch/Result.php b/library/ZendAfi/View/Helper/AuthoritySearch/Result.php
index cffde8f0256..346600c3f54 100644
--- a/library/ZendAfi/View/Helper/AuthoritySearch/Result.php
+++ b/library/ZendAfi/View/Helper/AuthoritySearch/Result.php
@@ -26,20 +26,20 @@ class ZendAfi_View_Helper_AuthoritySearch_Result extends ZendAfi_View_Helper_Bas
     $_record,
     $_record_path = [];
 
-  public function AuthoritySearch_Result($result, $record, $tree_roots) {
+  public function AuthoritySearch_Result($result, $record, $tree_roots, $select_for) {
     $this->_result = $result;
     $this->_record = $record;
 
     return $this->_tag('div',
-                       $this->_renderRecordOrResult()
+                       $this->_renderRecordOrResult($select_for)
                        . $this->_renderTree($tree_roots)
                        . $this->_tag('div', '', ['class' => 'clear']),
                        ['class' => 'conteneur_simple']);
   }
 
 
-  protected function _renderRecordOrResult() {
-    $html = ($record = $this->view->AuthoritySearch_Record($this->_record))
+  protected function _renderRecordOrResult($select_for) {
+    $html = ($record = $this->view->AuthoritySearch_Record($this->_record, $select_for))
       ? $record
       : $this->_renderRecords();
 
diff --git a/library/ZendAfi/View/Helper/TagCriteresRecherche.php b/library/ZendAfi/View/Helper/TagCriteresRecherche.php
index 9b30ff62f34..b5943300821 100644
--- a/library/ZendAfi/View/Helper/TagCriteresRecherche.php
+++ b/library/ZendAfi/View/Helper/TagCriteresRecherche.php
@@ -57,8 +57,7 @@ class ZendAfi_View_Helper_TagCriteresRecherche extends ZendAfi_View_Helper_BaseH
       'dewey' => ($libelle = Class_AdminVar::get('FACETTE_DEWEY_LIBELLE')) ? $libelle :$this->_('Dewey / pcdm4'),
       'collection' => $this->_('Collection') ];
 
-    if (isset($criteres_recherche))
-      $criteres_recherche->acceptVisitor($this);
+    $criteres_recherche->acceptVisitor($this);
   }
 
 
@@ -234,4 +233,11 @@ class ZendAfi_View_Helper_TagCriteresRecherche extends ZendAfi_View_Helper_BaseH
 
     $this->htmlAppend($this->getSuppressionImgUrlForLibelle($label, $url));
   }
+
+
+  public function visitAuthority($authority) {
+    $label = $authority->getTypeLabel() . ': ' . $authority->getLabel();
+    $url = $this->_criteres_recherche->getUrlRemoveAuthority($authority);
+    $this->htmlAppend($this->getSuppressionImgUrlForLibelle($label, $url));
+  }
 }
\ No newline at end of file
diff --git a/public/opac/css/global.css b/public/opac/css/global.css
index 68f09d0a805..aa70f33ecfa 100644
--- a/public/opac/css/global.css
+++ b/public/opac/css/global.css
@@ -3788,4 +3788,11 @@ a[href*="bookmarked-searches/notify"] img {
 #holds_view + ul {
     justify-content: center;
     display: flex;
+}
+
+
+/** ARIA utilities **/
+.visuallyhidden {
+    position: absolute;
+    top:-9999px;
 }
\ No newline at end of file
diff --git a/public/opac/js/authority-search/style.css b/public/opac/js/authority-search/style.css
new file mode 100644
index 00000000000..093b0d85095
--- /dev/null
+++ b/public/opac/js/authority-search/style.css
@@ -0,0 +1,122 @@
+@supports (display: grid) {
+    .grid-wrapper {
+        margin-top: 10px;
+        display:grid;
+        grid-template-columns: 3fr 1fr 3fr 1fr 3fr;
+        grid-gap: 0;
+        grid-auto-rows: minmax(15px, auto);
+    }
+
+
+    .grid-wrapper div {
+        border: 1px solid black;
+    }
+
+
+    .grid-wrapper div.arrow {
+        border: none;
+    }
+
+
+    .grid-wrapper div.arrow i {
+        border: solid black;
+        border-width: 0 3px 3px 0;
+        display: inline-block;
+        padding: 3px;
+    }
+
+    .grid-wrapper div.east {
+        grid-column: 4 / 5;
+        grid-row: 3;
+        text-align: center;
+        align-self: center;
+    }
+
+    .grid-wrapper div.west i,
+    .grid-wrapper div.east i{
+        transform: rotate(-45deg);
+        -webkit-transform: rotate(-45deg);
+    }
+
+
+    .grid-wrapper div.west {
+        grid-column: 2 / 3;
+        grid-row: 3;
+        text-align: center;
+        align-self: center;
+    }
+
+
+    .grid-wrapper div.north {
+        grid-column: 3 / 4;
+        grid-row: 2;
+        text-align: center;
+        align-self: center;
+    }
+
+    .grid-wrapper div.north i,
+    .grid-wrapper div.south i{
+        transform: rotate(45deg);
+        -webkit-transform: rotate(45deg);
+    }
+
+
+    .grid-wrapper div.south {
+        grid-column: 3 / 4;
+        grid-row: 4;
+        text-align: center;
+        align-self: center;
+    }
+
+
+    .grid-wrapper div h3 {
+        font-size: 1.1em;
+        text-align: center;
+        padding: 4px;
+        margin: 0;
+        border-bottom: 1px solid black;
+    }
+
+
+    .grid-wrapper div ul {
+        list-style: none;
+        padding: 10px;
+        margin: 0;
+    }
+
+
+    .grid-wrapper .term {
+        grid-column: 3 / 4;
+        grid-row: 3;
+    }
+
+
+    .grid-wrapper .term h2 {
+        text-align: center;
+    }
+
+
+
+    .grid-wrapper .generic {
+        grid-column: 3 / 4;
+        grid-row: 1;
+    }
+
+
+    .grid-wrapper .reject {
+        grid-column: 1 / 2;
+        grid-row: 3;
+    }
+
+
+    .grid-wrapper .link {
+        grid-column: 5 / 6;
+        grid-row: 3;
+    }
+
+
+    .grid-wrapper .specific {
+        grid-column: 3 / 4;
+        grid-row: 5;
+    }
+}
\ No newline at end of file
diff --git a/public/opac/js/authority_picker/authority_picker.js b/public/opac/js/authority_picker/authority_picker.js
new file mode 100644
index 00000000000..2deb8b13f5e
--- /dev/null
+++ b/public/opac/js/authority_picker/authority_picker.js
@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) 2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
+ */
+
+(function( $ ) {
+  $.fn.authority_picker = function( options ) {
+    if (!options.name || !options.pick_url)
+      return;
+
+    var value_holder = this.find('#'+ options.name);
+    var pick_button = this.find('button').eq(0);    
+    var reset_button = this.find('button').eq(1);    
+    var label_span = this.find('span').first();
+
+    opacDialogRegisterOnOpen(function() {
+      $('#opac-dialog button.authority_pick').click(on_pick);
+    });
+
+    pick_button.click(function(e) {
+      e.preventDefault();
+      var suffix = '/render/popup';
+      var current_value = value_holder.val();
+      if (current_value)
+	suffix += '/record_id/' + current_value;
+      
+      opacDialogFromUrl(addPath(options.pick_url, suffix));
+    });
+
+    reset_button.click(function(e) {
+      e.preventDefault();
+      value_holder.val('');
+      label_span.html('non sélectionné');
+    });
+
+    function on_pick(e) {
+      e.preventDefault();
+      var id = $(this).attr('data-record');
+      var label = $(this).attr('data-label');
+      label_span.html(label);
+      value_holder.val(id);
+      opacDialogClose();
+    }
+  };
+} (jQuery));
diff --git a/public/opac/js/authority_picker/tests.html b/public/opac/js/authority_picker/tests.html
new file mode 100644
index 00000000000..bc9c0600a21
--- /dev/null
+++ b/public/opac/js/authority_picker/tests.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<!--
+/**
+ * Copyright (c) 2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA 
+ */
+-->
+<html>
+  <head>
+    <meta charset="utf-8">
+    <title>QUnit tests</title>
+    <link rel="stylesheet" href="http://code.jquery.com/qunit/qunit-git.css">
+  </head>
+  <body>
+    <div id="qunit"></div>
+    <div id="qunit-fixture"></div>
+    <script type="text/javascript" src="../../../admin/js/jquery-3.2.1.min.js"></script>
+    <script src="authority_picker.js"></script>
+    <script src="http://code.jquery.com/qunit/qunit-1.13.0.js"></script>
+    <script src="tests.js"></script>
+    <div id="opac-dialog">
+      <h2>Authority
+	<button class="authority_pick" data-record="3334923" data-label="Authority">Choisir</button>
+      </h2>
+    </div>
+  </body>
+</html>
diff --git a/public/opac/js/authority_picker/tests.js b/public/opac/js/authority_picker/tests.js
new file mode 100644
index 00000000000..e5d69eaaff8
--- /dev/null
+++ b/public/opac/js/authority_picker/tests.js
@@ -0,0 +1,100 @@
+/**
+ * Copyright (c) 2019, Agence Française Informatique (AFI). All rights reserved.
+ *
+ * BOKEH is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE as published by
+ * the Free Software Foundation.
+ *
+ * There are special exceptions to the terms and conditions of the AGPL as it
+ * is applied to this software (see README file).
+ *
+ * BOKEH is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+ *
+ * You should have received a copy of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * along with BOKEH; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA 
+ */
+
+function call_authority_picker_for(options) {
+  var insertion_point = $('<div>\
+<input type="hidden" id="authority_subject" name="authority_subject">\
+<span>non sélectionné</span>\
+<button class="button new" onclick="return false;" title="" data-url="/recherche/add">Choisir</button>\
+<button class="button undo" onclick="return false;" title="Annuler mes modifications" data-url="/recherche/avancee">Annuler</button>\
+</div>');
+  insertion_point.authority_picker(options);
+  return insertion_point;
+}
+
+
+var on_open_listeners = [];
+window.opacDialogRegisterOnOpen = function(callback) {
+  on_open_listeners.push(callback);
+  callback();
+}
+
+var opened_urls = [];
+window.opacDialogFromUrl = function(url) {
+  opened_urls.push(url);
+};
+
+window.opacDialogClose = function() {};
+window.confirm = function() { return true; };
+window.addPath = function(base, suffix) { return base + suffix; };
+
+var insertion_point;
+
+QUnit.module('authority_picker');
+
+QUnit.testStart(function() {
+  on_open_listeners = [];
+  opened_urls = [];
+  $('button.authority_pick').off('click');
+
+  insertion_point = call_authority_picker_for({
+    name:"authority_subject",
+    pick_url: "/authority-search/index/select_for/subject"
+  });
+});
+
+
+test('plugin is defined', function() {
+  ok($.fn.authority_picker);
+});
+
+
+test('on open register', function() {
+  equal(on_open_listeners.length, 1, 'opacDialogRegisterOnOpen has been called');
+});
+
+
+test('on empty', function () {
+  equal($._data(insertion_point.find("button")[0],'events').click.length, 1,
+        'pick button is clickable in ' + insertion_point.html());
+  equal($._data(insertion_point.find("button")[1],'events').click.length, 1,
+        'reset button is clickable in ' + insertion_point.html());
+
+  equal(insertion_point.find('input').first().val(), '', 'value should be empty' + insertion_point.html());
+  equal(insertion_point.find('span').first().html(), 'non sélectionné', 'label should be default' + insertion_point.html());
+
+  $('button.authority_pick').first().click();
+
+  equal(insertion_point.find('input').first().val(), '3334923', 'value has been set' + insertion_point.html());
+  equal(insertion_point.find('span').first().html(), 'Authority', 'label has been set' + insertion_point.html());
+
+  insertion_point.find("button")[1].click();
+  equal(insertion_point.find('input').first().val(), '', 'value has been reset' + insertion_point.html());
+  equal(insertion_point.find('span').first().html(), 'non sélectionné', 'label has been reset' + insertion_point.html());
+});
+
+
+test('on already selected', function() {
+  insertion_point.find('input').first().val('9998988');
+  insertion_point.find('span').first().html('Cosmo999');
+  insertion_point.find("button").first().click();
+
+  equal(opened_urls[0], '/authority-search/index/select_for/subject/render/popup/record_id/9998988');
+});
diff --git a/tests/application/modules/opac/controllers/RechercheControllerTest.php b/tests/application/modules/opac/controllers/RechercheControllerTest.php
index df8befcbeda..5774d00ab0b 100644
--- a/tests/application/modules/opac/controllers/RechercheControllerTest.php
+++ b/tests/application/modules/opac/controllers/RechercheControllerTest.php
@@ -3531,4 +3531,122 @@ class RechercheControllerNoExtensionTest extends AbstractControllerTestCase {
   public function noResultShouldBeDisplay() {
     $this->assertXPathContentContains('//div[@class="liste_notices"]/h2', 'Aucun résultat trouvé');
   }
+}
+
+
+
+
+abstract class RechercheControllerAuthoritiesTestCase extends RechercheControllerNoticeTestCase {
+  protected
+    $_default_storm_to_volatile = true,
+    $_current_id = 8898;
+
+  protected function _fixtureRecord($id_origine, $child_id='', $with_thesaurus=true) {
+    $autority = (new Class_Notice_AuthorityPartial)
+      ->newWith(Class_Notice_AuthorityType::SUBJECT,
+                $id_origine,
+                'Authority ' . $this->_current_id,
+                ['b' => 'CUSTOM']);
+
+    if ($child_id)
+      $autority->zoneWithChildren('550', ['3' => $child_id, '5' => 'h']);
+
+    $thesaurus = $with_thesaurus
+      ? ('HDOCU' . $this->_current_id)
+      : '';
+
+    $this->fixture('Class_Notice',
+                   ['id' => $this->_current_id,
+                    'type_doc' => Class_Notice_AuthorityType::SUBJECT,
+                    'facettes' => 'HDOCU ' . $thesaurus,
+                    'type' => Class_Notice::TYPE_AUTHORITY,
+                    'unimarc' => $autority->render()]);
+
+    $this->fixture('Class_Exemplaire',
+                   ['id' => $this->_current_id,
+                    'type' => Class_Notice::TYPE_AUTHORITY,
+                    'id_origine' => $id_origine,
+                    'id_notice' => $this->_current_id]);
+
+    $this->_current_id++;
+  }
+}
+
+
+
+
+class RechercheControllerAuthoritiesRecordHeirarchyWithHolesTest
+  extends RechercheControllerAuthoritiesTestCase {
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->fixture('Class_CodifThesaurus',
+                   ['id' => 78,
+                    'id_thesaurus' => 'DOCU',
+                    'libelle' => 'Documentary']);
+
+    $this->_fixtureRecord('189260', '111111', false);
+    $this->_fixtureRecord('111111', '222222');
+    $this->_fixtureRecord('222222', '333333', false);
+    $this->_fixtureRecord('333333', '');
+
+    $this->mock_sql = $this->mock()->beStrict();
+    Zend_Registry::set('sql', $this->mock_sql);
+  }
+
+
+  /** @test */
+  public function withoutHierarchyAndNoFacetShouldQueryOnDummyFacet() {
+    $this->mock_sql->whenCalled('fetchAll')
+                   ->with("select id_notice, facettes from notices Where (MATCH(facettes) AGAINST('+(H0000)' IN BOOLEAN MODE)) and type=1", true, false)
+                   ->answers([]);
+
+    $this->dispatch('/opac/recherche/simple/authorities/HDOCU_8898_0');
+    $this->assertTrue($this->mock_sql->methodHasBeenCalled('fetchAll'));
+  }
+
+
+  /** @test */
+  public function withoutHierarchyAndNoFacetShouldDisplayDocumentaryAuthority8898() {
+    $this->mock_sql->whenCalled('fetchAll')
+                   ->with("select id_notice, facettes from notices Where (MATCH(facettes) AGAINST('+(H0000)' IN BOOLEAN MODE)) and type=1", true, false)
+                   ->answers([]);
+
+    $this->dispatch('/opac/recherche/simple/authorities/HDOCU_8898_0');
+    $this->assertXPathContentContains('//a', 'Documentary: Authority 8898');
+  }
+
+
+  /** @test */
+  public function withoutHierarchyAndLocalFacetShouldQueryOnLocalFacet() {
+    $this->mock_sql->whenCalled('fetchAll')
+                   ->with("select id_notice, facettes from notices Where (MATCH(facettes) AGAINST('+(HDOCU8899)' IN BOOLEAN MODE)) and type=1", true, false)
+                   ->answers([]);
+
+    $this->dispatch('/opac/recherche/simple/authorities/HDOCU_8899_0');
+    $this->assertTrue($this->mock_sql->methodHasBeenCalled('fetchAll'));
+  }
+
+
+  /** @test */
+  public function withHierarchyAndNoFacetShouldQueryOnChildrenFacets() {
+    $this->mock_sql->whenCalled('fetchAll')
+                   ->with("select id_notice, facettes from notices Where (MATCH(facettes) AGAINST('+(HDOCU8899 HDOCU8901)' IN BOOLEAN MODE)) and type=1", true, false)
+                   ->answers([]);
+
+    $this->dispatch('/opac/recherche/simple/authorities/HDOCU_8898_1');
+    $this->assertTrue($this->mock_sql->methodHasBeenCalled('fetchAll'));
+  }
+
+
+  /** @test */
+  public function withHierarchyAndLocalFacetShouldQueryOnLocalAndChildrenFacet() {
+    $this->mock_sql->whenCalled('fetchAll')
+                   ->with("select id_notice, facettes from notices Where (MATCH(facettes) AGAINST('+(HDOCU8899 HDOCU8901)' IN BOOLEAN MODE)) and type=1", true, false)
+                   ->answers([]);
+
+    $this->dispatch('/opac/recherche/simple/authorities/HDOCU_8899_1');
+    $this->assertTrue($this->mock_sql->methodHasBeenCalled('fetchAll'));
+  }
 }
\ No newline at end of file
diff --git a/tests/scenarios/AdvancedSearch/AdvancedSearchTest.php b/tests/scenarios/AdvancedSearch/AdvancedSearchTest.php
index 2f6b309de68..ead89e07b1e 100644
--- a/tests/scenarios/AdvancedSearch/AdvancedSearchTest.php
+++ b/tests/scenarios/AdvancedSearch/AdvancedSearchTest.php
@@ -70,7 +70,7 @@ abstract class AdvancedSearchTestCase extends AbstractControllerTestCase {
 
 
 
-class AdvancedSearchTest extends AdvancedSearchTestCase {
+class AdvancedSearchDefaultTest extends AdvancedSearchTestCase {
   /** @test */
   public function formActionShouldBeRechercheSimple() {
     $this->assertXPath('//form[contains(@action,"/recherche/simple")]');
@@ -173,7 +173,7 @@ class AdvancedSearchTest extends AdvancedSearchTestCase {
 
 
 
-class AdvancedSearchWithCustomFormDefaultTest extends AdvancedSearchTest {
+class AdvancedSearchWithCustomFormDefaultTest extends AdvancedSearchDefaultTest {
   protected function _prepareFixtures() {
     parent::_prepareFixtures();
     Class_AdminVar::set('CUSTOM_SEARCH_FORM', 1);
@@ -202,6 +202,7 @@ abstract class AdvancedSearchCustomFormSelectedTestCase extends AdvancedSearchTe
   public function tearDown() {
     Class_FileManager::reset();
     Class_SearchForm::setIncluder(null);
+    Class_SearchForm::throwErrors(false);
 
     parent::tearDown();
   }
@@ -452,14 +453,13 @@ class AdvancedSearchFormWithDateSelectorsTest extends AdvancedSearchCustomFormSe
                     'libelle' => 'Jour de publication',
                     'id_thesaurus' => 'JPUB',
                     'id_origine' => null,
-                    'code' => 'JBUB',
+                    'code' => 'JPUB',
                     'rule_zone' => '995',
                     'rule_label_field' => 'w',
                     'rule_label_start_pos' => 9,
                     'rule_label_length' => 2
                    ]);
 
-
     $this->fixture('Class_CodifThesaurus',
                    ['id' => 34,
                     'libelle' => '3',
@@ -534,8 +534,76 @@ class AdvancedSearchFormWithDateSelectorsTest extends AdvancedSearchCustomFormSe
 
 
 
-abstract class AdvancedSearchValidCustomFormsSelectedAndPublishedTestCase extends AdvancedSearchCustomFormSelectedTestCase {
-    protected function _prepareFixtures() {
+class AdvancedSearchFormWithAuthoritiesCriteriaTest
+  extends AdvancedSearchCustomFormSelectedTestCase {
+
+  protected function _prepareFixtures() {
+    parent::_prepareFixtures();
+
+    $form_filename = 'userfiles/forms/form.php';
+    $this->fixture('Class_SearchForm', ['id' => 3, 'filename' => $form_filename]);
+    $file_system = $this->mock()
+                        ->whenCalled('directoryAt')->with($form_filename)->answers(false)
+                        ->whenCalled('fileAt')->with($form_filename)
+                        ->answers((new Class_FileManager)
+                                  ->setId($form_filename)
+                                  ->setRealpath($form_filename)
+                                  ->setPath($form_filename)
+                                  ->setName('form.php')
+                                  ->setBasename('form.php')
+                                  ->setParentPath('userfiles/forms')
+                                  ->setWritable(true))
+                        ->whenCalled('getContent')->with($form_filename)
+                        ->answers('<?php ?>')
+      ;
+
+    Class_FileManager::setFileSystem($file_system);
+    Class_SearchForm::setIncluder(
+                                  function($path, $form) {
+                                    $form
+                                      ->addElement('authority', 'motcle',
+                                                   ['label' => 'Mot-clé TESS',
+                                                    'facets' => 'HMOTS',
+                                                    'tree_roots' => '230988'])
+                                      ->addUniqDisplayGroup('yeah');
+                                  });
+
+    Class_SearchForm::throwErrors(true);
+  }
+
+
+  /** @test */
+  public function authorityCriteriaShouldBePresent() {
+    $this->assertXPath('//input[@name="authority_motcle"]');
+  }
+
+
+  /** @test */
+  public function authorityModeShouldBePresent() {
+    $this->assertXPath('//input[@type="checkbox"][@name="mode_authority_motcle"][@checked]');
+  }
+
+
+  /** @test */
+  public function buttonToSelectAuthorityShouldBePresent() {
+    $this->assertXPathContentContains('//button', 'Choisir');
+  }
+
+
+  /** @test */
+  public function scriptToSelectAuthorityShouldBePresent() {
+    $this->assertXPath('//script[contains(@src, "/opac/js/authority_picker/authority_picker.js")]');
+    $this->assertXPathContentContains('//script', 'pick_url:');
+  }
+}
+
+
+
+
+abstract class AdvancedSearchValidCustomFormsSelectedAndPublishedTestCase
+  extends AdvancedSearchCustomFormSelectedTestCase {
+
+  protected function _prepareFixtures() {
     parent::_prepareFixtures();
 
     $author_search_form = 'userfiles/forms/author_search_form.php';
@@ -602,7 +670,8 @@ abstract class AdvancedSearchValidCustomFormsSelectedAndPublishedTestCase extend
 
 
 
-class AdvancedSearchValidCustomFormsSelectedAndPublishedTest extends AdvancedSearchValidCustomFormsSelectedAndPublishedTestCase {
+class AdvancedSearchValidCustomFormsSelectedAndPublishedTest
+  extends AdvancedSearchValidCustomFormsSelectedAndPublishedTestCase {
 
   /** @test */
   public function authorFieldShouldBePresent() {
@@ -642,24 +711,21 @@ class AdvancedSearchValidCustomFormsSelectedAndPublishedTest extends AdvancedSea
 
   /** @test */
   public function formOnFirstTabActionShouldBeRechercheSimpleFormIdZero() {
-    $this->assertXPath('//form[contains(@action, "/recherche/simple/form_id/0")]');
+    $this->assertXPath('//form[contains(@action, "/recherche/simple/")][contains(@action, "/form_id/0")]');
   }
 
 
   /** @test */
   public function formOnSecondTabActionShouldBeRechercheSimpleFormIdOne() {
-    $this->assertXPath('//form[contains(@action, "/recherche/simple/form_id/1")]');
+    $this->assertXPath('//form[contains(@action, "/recherche/simple/")][contains(@action, "/form_id/1")]');
   }
 }
 
 
 
-class AdvancedSearchResetSecondFormTest extends AdvancedSearchValidCustomFormsSelectedAndPublishedTestCase {
-  public function setUp() {
-    AbstractControllerTestCase::setUp();
 
-    $this->_prepareFixtures();
-  }
+class AdvancedSearchResetSecondFormTest
+  extends AdvancedSearchValidCustomFormsSelectedAndPublishedTestCase {
 
   /** @test */
   public function resetFormIdOneShouldActivateFormIdOne() {
@@ -809,9 +875,9 @@ class AdvancedSearchMultiFacetsPostDispatchTest extends Admin_AbstractController
 
   /** @test */
   public function mixMultifacetsAndDynamicFacetsShouldMergeFacets() {
-    $this->postDispatch('/recherche/simple',   [ 'custom_multifacets_author' => ['A12', 'A42'],
-                                                 'custom_multifacets_subject' => 'M608',
-                                                 'rech_HDOCU' => 'SIFI']);
+    $this->postDispatch('/recherche/simple', ['custom_multifacets_author' => ['A12', 'A42'],
+                                              'custom_multifacets_subject' => 'M608',
+                                              'rech_HDOCU' => 'SIFI']);
     $this->assertRedirectTo('/recherche/simple/multifacets/HDOCU0001-A12-A42-M608');
   }
 }
\ No newline at end of file
diff --git a/tests/scenarios/Authorities/AuthoritiesTest.php b/tests/scenarios/Authorities/AuthoritiesTest.php
index 12e313c9e1c..691a7529a65 100644
--- a/tests/scenarios/Authorities/AuthoritiesTest.php
+++ b/tests/scenarios/Authorities/AuthoritiesTest.php
@@ -383,6 +383,23 @@ class AuthoritiesAuthoritySearchControllerWithRecordIdTest
 
 
 
+class AuthoritiesAuthoritySearchControllerWithRecordIdInPopupTest
+  extends AuthoritiesAuthoritySearchControllerWithRecordIdTest {
+
+  protected function _urlToRecord($id) {
+    return '/authority-search/index/select_for/auth/expressionRecherche/local/record_id/' . $id;
+  }
+
+
+  /** @test */
+  public function shouldContainsLinkToSelectCurrentAuthority() {
+    $this->assertXPathContentContains('//button', 'Choisir');
+  }
+}
+
+
+
+
 class AuthoritiesNoticeAjaxControllerTest extends AuthoritiesTestCase {
   public function setUp() {
     parent::setUp();
diff --git a/tests/scenarios/SearchResult/SearchResultTest.php b/tests/scenarios/SearchResult/SearchResultTest.php
index 2e4c525cbd2..9bfed222d7b 100644
--- a/tests/scenarios/SearchResult/SearchResultTest.php
+++ b/tests/scenarios/SearchResult/SearchResultTest.php
@@ -271,8 +271,6 @@ class SearchResultWithDynamicFacetTest extends AbstractControllerTestCase {
                     'code' => 'DOCU',
                     'rule_zone' => '995',
                     'rule_label_field' => 't']);
-
-
   }
 
 
@@ -281,4 +279,63 @@ class SearchResultWithDynamicFacetTest extends AbstractControllerTestCase {
     $this->dispatch('/opac/recherche/simple/rech_HDOCU/SIFI', true);
     $this->assertRedirectTo('/recherche/simple/multifacets/HDOCU0001');
   }
-}
\ No newline at end of file
+}
+
+
+
+
+class SearchResultWithAuthorityRecordTest
+  extends AbstractControllerTestCase {
+
+  protected
+    $_storm_default_to_volatile = true,
+    $_current_id = 8898;
+
+  public function setUp() {
+    parent::setUp();
+
+    $this->_fixtureRecord('189260');
+    $this->_fixtureRecord('190455');
+  }
+
+
+  protected function _fixtureRecord($id_origine, $child_id='', $with_thesaurus=true) {
+    $autority = (new Class_Notice_AuthorityPartial)
+      ->newWith(Class_Notice_AuthorityType::SUBJECT,
+                $id_origine,
+                uniqid(),
+                ['b' => 'CUSTOM']);
+
+    if ($child_id)
+      $autority->zoneWithChildren('550', ['3' => $child_id, '5' => 'h']);
+
+    $thesaurus = $with_thesaurus
+      ? ('HDOCU' . $this->_current_id)
+      : '';
+
+    $this->fixture('Class_Notice',
+                   ['id' => $this->_current_id,
+                    'facettes' => 'HDOCU ' . $thesaurus,
+                    'type' => Class_Notice::TYPE_AUTHORITY,
+                    'unimarc' => $autority->render()]);
+
+    $this->fixture('Class_Exemplaire',
+                   ['id' => $this->_current_id,
+                    'type' => Class_Notice::TYPE_AUTHORITY,
+                    'id_origine' => $id_origine,
+                    'id_notice' => $this->_current_id]);
+
+    $this->_current_id++;
+  }
+
+
+  /** @test */
+  public function shouldRedirectToSearchWith8898NoHierarchyAnd8899AndItsHierarchy() {
+    $this->postDispatch('/opac/recherche/simple',
+                        ['authority_anything' => '8898',
+                         'mode_authority_anything' => '0',
+                         'authority_nothing' => '8899',
+                         'mode_authority_nothing' => '1']);
+    $this->assertRedirectTo('/recherche/simple/authorities/HDOCU_8898_0-HDOCU_8899_1');
+  }
+}
-- 
GitLab