My answer: there is no exotic, secret knowledge. Use autocompletion and a database as you would with any other tools/libraries.
To describe my use-case a little: users submit requests for an interpreter for a future court proceeding. They have to provide the name(s) of the defendant(s), among other data (e.g., language). There are over 12K such names already in our database and we re-use recurring names (IOW, the entity represents a person's proper name rather than a person).
Now for some code excerpts. In the front end, the view script:
<?php
$this->headScript()->appendFile('/dev-requests/js/jquery-ui.min.js');
$this->headScript()->appendFile('/dev-requests/js/defts.js');
// stuff omitted...
// within the form:
// $element is a \Zend\Form\Element\Select with attribute 'multiple' => 'multiple', zero options,
// and it's hidden via css because no one needs to see it
<div class="form-group">
<label class="control-label col-sm-3" for="<?=$element->getName()?>"><?=$element->getLabel()?></label>
<div class="col-sm-9"><?= $this->formElement($element)?><?= $this->formElementErrors($element) ?>
<?php if ($this->defendants):
// if we are an update (as opposed to create) action, our controller's updateAction
// will have set $this->defendants to a (possibly empty) array of 'defendantName' entities
foreach($this->defendants as $deft): ?>
<div id="deft-div-<?=$deft->getDeftId()?>"><span class="remove-div"><a href="#">[x]</a></span>
<?=$deft->getFullname()?>
<input value="<?=$deft->getDeftId()?>" name="request-fieldset[defendantNames][]" type="hidden">
</div>
<?php
endforeach;
endif;
?>
</div>
</div>
And some Javascript from defts.js
, inside our document.ready() callback:
// the autocomplete textfield itself
$('#deft-select').after(
$('<input>').attr({id:'deftname-autocomplete',size:25})
);
// for deleting a name from the form
($('form').on('click','span.remove-div',function(event){
event.preventDefault();
$(this).parent().slideUp(function(){$(this).remove();});
}));
$('#deftname-autocomplete').autocomplete({
source : '/dev-requests/defendants/autocomplete',
select: function( event, ui ) {
// add a human-readable label and hidden form element
$(this).val('');
var elementName = $('#deft-select').attr('name');
var deftName = ui.item.label;
var deft_id = ui.item.value;
if ($( '#deft-div-'+ deft_id ).length) {
return false; // already exists
}
var div = $(this).closest('div');
div.append(
$('<div/>').attr({id: "deft-div-"+ deft_id})
.html([
'<span class="remove-div"><a href="#">[x]</a></span> ' + deftName,
$('<input/>').attr({type:'hidden',name:elementName}).val(deft_id)
])
);
return false;
}
});
In our controller:
public function autocompleteAction()
{
$term = $this->getRequest()->getQuery('term');
if (! $term) { return false; }
/**
* @var $em Doctrine\ORM\EntityManager
*/
$em = $this->getServiceLocator()->get('entity-manager');
/**
* @var $repo Application\Entity\DefendantNameRepository
*/
$repo = $em->getRepository('Application\Entity\DefendantName');
$data = json_encode($repo->autocomplete($term));
$response = $this->getResponse();
$response->getHeaders()->addHeaders(['Content-type'=>'application/json;charset=UTF-8']);
return $response->setContent($data);
}
In our custom Doctrine repository, Application\Entity\DefendantNameRepository:
/**
* return array of value/label for autocompletion via xhr
* @param string $term name
* @param int limit max number of records to return
*
* $term is expected to be proper name in the format la[stname][,f[irstname]]
*/
public function autocomplete($term, $limit = 20) {
/**
* @var $connection Doctrine\DBAL\Connection
*/
$connection = $this->getEntityManager()->getConnection();
list($lastname,$firstname) = $this->parseName($term);
if (! strstr($lastname,'-')) {
$where = 'lastname LIKE '.$connection->quote("$lastname%");
} else {
// they frequently insert gratuitous hyphens between the
// paternal and maternal surnames of Spanish-speaking people
$lastname = str_replace('-','( |-)',$lastname);
$where = 'lastname REGEXP '.$connection->quote("^$lastname");
}
if ($firstname) {
$where .= " AND firstname LIKE ".$connection->quote("$firstname%");
} else {
$where .= " AND firstname <> '' "; // some old records have no firstname, but we don't like that
}
$sql = 'SELECT CONCAT(lastname, ", ",firstname) AS label, deft_id AS value
FROM deft_names WHERE '.$where . " ORDER BY lastname, firstname LIMIT $limit ";
return $connection->fetchAll($sql);
}
...and this little helper, elsewhere in our repository:
/**
* parses first and last names out of $name. expected format is
* la[stname][,f[irstname]]
* @param string $name
* @return array ($lastname, $firstname)
*/
public function parseName($name) {
$name = preg_split('/ *, */',trim($name),2,PREG_SPLIT_NO_EMPTY);
if (2 == sizeof($name)) {
list($last, $first) = $name;
} else {
$last = $name[0];
$first = false;
}
return array($last,$first);
}
The class definition Application\Entity\DefendantName is straightforward and omitted for brevity.
Still to do: add a search button thingy to the right of our autocompleting text element for them to click when they get no autocompletion matches so we can tell them "sorry, no matching records found." And -- though this is tangential to the original question -- provide a way for them to submit names we've never heard of.