From 2e22e974462b5bd928867da00285baefe360fb61 Mon Sep 17 00:00:00 2001 From: Jason Morriss Date: Tue, 19 Jul 2016 08:51:22 -0400 Subject: [PATCH] Several updates: * Option `class` is now optional. If not specified the Typeahead input will allow for non-entity lookups. The data passed is simply not transformed. It's up to your backend to do something useful with the values. * Symfony form names with underscores will now work properly (eg:
). Previously, form names with underscores would not POST properly. * Replaced deprecated PropertyAccess::getPropertyAccessor() call with PropertyAccess::createPropertyAccessor(). * Removed unused TypeExtension. * EntitiesToPropertyTransformer will no longer return an ArrayCollection. It returns a simple array instead. * The `route` will receive extra parameters now to help it make decisions on how to return the results. * Implemented `source` option to allow for a custom callback function that can return a list of matches (doesn't need to be AJAX). --- .../EntitiesToPropertyTransformer.php | 42 +++--- .../EntityToPropertyTransformer.php | 34 +++-- Form/Extension/TypeaheadTypeExtension.php | 28 ---- Form/Type/TypeaheadType.php | 24 ++-- README.md | 136 ++++++++++++------ Resources/config/services.yml | 7 - Resources/public/js/typeaheadbundle.js | 103 +++++++------ Resources/views/Form/typeahead.html.twig | 18 ++- 8 files changed, 225 insertions(+), 167 deletions(-) delete mode 100644 Form/Extension/TypeaheadTypeExtension.php diff --git a/Form/DataTransformer/EntitiesToPropertyTransformer.php b/Form/DataTransformer/EntitiesToPropertyTransformer.php index 2808b69..ba11f07 100644 --- a/Form/DataTransformer/EntitiesToPropertyTransformer.php +++ b/Form/DataTransformer/EntitiesToPropertyTransformer.php @@ -2,13 +2,10 @@ namespace Lifo\TypeaheadBundle\Form\DataTransformer; -use Symfony\Component\Form\DataTransformerInterface; +use Doctrine\Common\Collections\Collection; +use Doctrine\DBAL\Driver\DriverException; use Symfony\Component\Form\Exception\TransformationFailedException; use Symfony\Component\Form\Exception\UnexpectedTypeException; -use Symfony\Component\PropertyAccess\PropertyAccess; -use Doctrine\ORM\EntityManager; -use Doctrine\Common\Collections\Collection; -use Doctrine\Common\Collections\ArrayCollection; class EntitiesToPropertyTransformer extends EntityToPropertyTransformer { @@ -26,14 +23,18 @@ public function transform($array) throw new UnexpectedTypeException($array, 'array'); } - $return = array(); - foreach ($array as $entity) { - $value = parent::transform($entity); - if ($value !== null) { - $return[] = $value; + if ($this->className) { + $return = array(); + foreach ($array as $entity) { + $value = parent::transform($entity); + if ($value !== null) { + $return[] = $value; + } } + return $return; } - return $return; + + return $array; } @@ -47,13 +48,20 @@ public function reverseTransform($array) throw new UnexpectedTypeException($array, 'array'); } - $return = new ArrayCollection(); - foreach ($array as $value) { - $entity = parent::reverseTransform($value); - if ($value !== null) { - $return[] = $entity; + if ($this->className) { + try { + return $this->em->createQueryBuilder() + ->select('e') + ->from($this->className, 'e') + ->where('e.' . $this->property . ' IN (:ids)') + ->setParameter('ids', $array) + ->getQuery() + ->getResult(); + } catch (DriverException $ex) { + throw new TransformationFailedException('One or more "' . $this->property . '" values are invalid'); } } - return $return; + + return $array; } } diff --git a/Form/DataTransformer/EntityToPropertyTransformer.php b/Form/DataTransformer/EntityToPropertyTransformer.php index c1eb9ff..f109c23 100644 --- a/Form/DataTransformer/EntityToPropertyTransformer.php +++ b/Form/DataTransformer/EntityToPropertyTransformer.php @@ -16,6 +16,7 @@ class EntityToPropertyTransformer implements DataTransformerInterface protected $className; protected $property; protected $unitOfWork; + protected $accessor; public function __construct(EntityManager $em, $class, $property = 'id') { @@ -23,37 +24,42 @@ public function __construct(EntityManager $em, $class, $property = 'id') $this->unitOfWork = $this->em->getUnitOfWork(); $this->className = $class; $this->property = $property; + $this->accessor = PropertyAccess::createPropertyAccessor(); } public function transform($entity) { - if (null === $entity) { + if (empty($entity)) { return null; } - //if (!$this->unitOfWork->isInIdentityMap($entity) and !$this->unitOfWork->isScheduledForInsert($entity)) { - // throw new TransformationFailedException("Entities must be managed"); - //} + if ($this->className) { + if (!empty($this->property)) { + return $this->accessor->getValue($entity, $this->property); + } else { + return current($this->unitOfWork->getEntityIdentifier($entity)); + } + } - return !empty($this->property) - ? PropertyAccess::getPropertyAccessor()->getValue($entity, $this->property) - : current($this->unitOfWork->getEntityIdentifier($entity)); + return $entity; } public function reverseTransform($value) { - if ($value === '' or $value === null) { + if (empty($value)) { return null; } - $repo = $this->em->getRepository($this->className); - if (!empty($this->property)) { - $entity = $repo->findOneBy(array($this->property => $value)); - } else { - $entity = $repo->find($value); + if ($this->className) { + $repo = $this->em->getRepository($this->className); + if (!empty($this->property)) { + return $repo->findOneBy(array($this->property => $value)); + } else { + return $repo->find($value); + } } - return $entity; + return $value; } } diff --git a/Form/Extension/TypeaheadTypeExtension.php b/Form/Extension/TypeaheadTypeExtension.php deleted file mode 100644 index c46e494..0000000 --- a/Form/Extension/TypeaheadTypeExtension.php +++ /dev/null @@ -1,28 +0,0 @@ -vars[...]; - } - public function setDefaultOptions(OptionsResolverInterface $resolver) - { - $resolver->setDefaults(array( - )); - } - public function getExtendedType() - { - return 'form'; - } -} diff --git a/Form/Type/TypeaheadType.php b/Form/Type/TypeaheadType.php index 89da154..9f98069 100644 --- a/Form/Type/TypeaheadType.php +++ b/Form/Type/TypeaheadType.php @@ -53,16 +53,16 @@ public function buildForm(FormBuilderInterface $builder, array $options) public function finishView(FormView $view, FormInterface $form, array $options) { parent::finishView($view, $form, $options); - //$cfg = $form->getConfig(); // assign some variables to the view template - $vars = array('render', 'route', 'route_params', 'property', - 'minLength', 'items', 'delay', 'spinner', - 'multiple', 'allow_add', 'allow_remove', 'empty_value', - 'resetOnSelect', 'callback'); + $vars = array( + 'render', 'route', 'route_params', 'property', 'minLength', 'items', 'delay', 'spinner', 'multiple', + 'allow_add', 'allow_remove', 'empty_value', 'resetOnSelect', 'callback', 'source', + ); foreach ($vars as $var) { $view->vars[$var] = $options[$var]; } + $view->vars['simple'] = empty($options['class']); // convert the route into an URL if (!empty($options['route'])) { @@ -93,28 +93,28 @@ public function setDefaultOptions(OptionsResolverInterface $resolver) */ public function configureOptions(OptionsResolver $resolver) { - $resolver->setRequired(array('class', 'render', 'route')); + $resolver->setRequired(array('render')); $resolver->setDefaults(array( 'em' => null, 'query_builder' => null, - 'property' => null, + 'class' => null, + 'property' => 'id', 'empty_value' => '', + 'route' => null, 'route_params' => null, - + 'source' => null, 'multiple' => false, 'allow_add' => false, 'allow_remove' => false, - 'delay' => 250, 'minLength' => 2, 'items' => 10, 'spinner' => 'glyphicon glyphicon-refresh spin', + 'callback' => null, + 'compound' => false, 'resetOnSelect' => function (Options $options) { return $options['multiple']; }, - 'callback' => null, - - 'compound' => false, //function(Options $options){ return $options['multiple']; }, )); } diff --git a/README.md b/README.md index d2eb7de..1f50b32 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ This is a [Symfony v2.2+](http://symfony.com/) Bundle that provides a The Typeahead component used in this bundle is the original Bootstrap v2.3 Typeahead library with a few enhancements. **Update: If you were pointing to `dev-master` in your composer.json and this bundle stopped working change your version to -"^1.x" and run `composer update lifo/typeahead` to update your project.** +"^1.*" and run `composer update lifo/typeahead` to update your project.** -* If you are using **Bootstrap v2.x** then you must use version **[1.x](https://github.com/lifo101/typeahead-bundle/tree/1.1)** +* If you are using **Bootstrap v2.x** then you must use version **[1.*](https://github.com/lifo101/typeahead-bundle/tree/1.1)** of this bundle. -* If you are using **Bootstrap v3.x** then you must use version **[2.x](https://github.com/lifo101/typeahead-bundle/tree/2.0)** +* If you are using **Bootstrap v3.x** then you must use version **[2.*](https://github.com/lifo101/typeahead-bundle/tree/2.0)** of this bundle. *Note: This bundle does not use the newer [Twitter/Typeahead.js](https://twitter.github.io/typeahead.js/) javascript library.* @@ -24,6 +24,8 @@ This bundle adds a few enhancements to the original bootstrap typeahead javascri * Delays AJAX request to reduce server requests * Properly handles pasting via mouse * Includes an `AJAX Loader` icon +* Supports Non-Entity lookup +* Supports non AJAX loading via a custom callback function ### Screenshots @@ -39,7 +41,7 @@ The entity in the backend is actually an ArrayCollection and automatically allow ## How to install **Note:** *This bundle requires jQuery and Bootstrap to be installed in your environment but does not include them -directly.* I suggest using the [braincrafted/bootstrap-bundle](https://github.com/braincrafted/bootstrap-bundle) +directly.* I suggest using the [mopa/bootstrap-bundle](https://github.com/braincrafted/bootstrap-bundle) which can help with this for you. * Add `lifo/typeahead-bundle` to the "requires" section of your project's `composer.json` file, which can be done @@ -61,7 +63,7 @@ automatically by running the composer command from within your project directory } ``` -* Run `composer update lifo/typeahead-bundle` in your project root. +* Run `composer update` in your project root. * Update your project `app/AppKernel.php` file and add this bundle to the $bundles array: ```php @@ -75,26 +77,30 @@ automatically by running the composer command from within your project directory Your actual setup may differ. Be sure to include it AFTER your jquery and bootstrap libraries. ```twig - {% javascripts filter='?yui_js' output='js/site.js' - // ... - '@lifo_typeahead_js' - // ... - %} - - {% endjavascripts %} + {% javascripts filter='?yui_js' output='js/site.js' + '@lifo_typeahead_js' + %} + + {% endjavascripts %} ``` * Add `@lifo_typeahead_css` to your Assetic `stylesheets` block. Similar to the block below. Your actual setup may differ. ```twig - {% stylesheets filter='less,cssrewrite,?yui_css' output='css/site.css' - // ... - '@lifo_typeahead_css' - // ... - -%} + {% stylesheets filter='cssrewrite,?yui_css' output='css/site.css' + '@lifo_typeahead_css' + -%} - {% endstylesheets %} + {% endstylesheets %} + ``` + +* If you're not using `Assetic` then you will have to manually include the javascript and css files into your project. + + ```twig + + + ``` ## How to use @@ -103,33 +109,44 @@ Using the typeahead control is extremely simple. The available options are outli ```php $builder->add('user', 'entity_typeahead', array( - 'class' => 'MyBundle:User', - 'render' => 'username', - 'route' => 'user_list', + 'class' => 'MyBundle:User', + 'render' => 'fullname', + 'route' => 'user_list', )); ``` -* **Required Options** - * `class` is your entity class. - * `render` is the property of your entity to display in the autocomplete menu. - * `route` is the name of the route to fetch entities from. The controller matching the route will receive the - following parameters via `POST`: - * `query` The query string to filter results by. - * `limit` The maximum number of results to return. -* **Optional Options** - * `route_params` Extra parameters to pass to the `route`. - * `minLength` Minimum characters needed before firing AJAX request. - * `items` Maximum items to display at once *(default: 8)* - * `delay` Delay in milliseconds before firing AJAX *(default: 250)* - * `spinner` Class string to use for loading spinner *(default: "glyphicon glyphicon-refresh spin")* - * `multiple` If true the widget will allow multiple entities to be selected. One at a time. This special mode creates - an unordered list below the typeahead widget to display the selected entities. - * `callback` Callback function (or string) that is called when an item is selected. Prototype: `function(text, data)` - where `text` is the label of the selected item and `data` is the JSON object returned by the server. +## Options +* `class` is your entity class. If `null` (or not specified), the items returned from your controller AJAX response +do not have to be Entities. If not blank, the class is used to map the items to your DB Entities. +* `source` is the name of a function to call that will collect items to display. This or `route` must be specified. +The prototype is: `function(query, process)` where `process` is the callback your function should call +after you've fetched your list of matching items. It expects a **FLAT** array of strings to render into the +pull down menu _(Not {id:'...', value:'...'} objects!)_. See the example below for more information. +* `route` is the name of the route to fetch entities from. The controller matching the route will receive the +following parameters via `POST`: + * `query` The query string to filter results by. + * `limit` The maximum number of results to return. + * `render` The configured `render` name. + This is what you should use to set the `value` attribute in the AJAX response. + * `property` The configured `property` name. Normally this is `id`. + This is what you should use to set the `id` attribute in the AJAX response. +* `route_params` Extra parameters to pass to the `route`. +* `minLength` Minimum characters needed before firing AJAX request. +* `items` Maximum items to display at once *(default: 8)* +* `delay` Delay in milliseconds before firing AJAX *(default: 250)* +* `spinner` Class string to use for loading spinner *(default: "glyphicon glyphicon-refresh spin")* +**Font-Awesome** example: *"fa fa-refresh fa-spin fa-fw"* +* `multiple` If true the widget will allow multiple entities to be selected. One at a time. This special mode +creates an unordered list below the typeahead widget to display the selected entities. +* `callback` Callback function (or string) that is called when an item is selected. Prototype: `function(text, data)` +where `text` is the label of the selected item and `data` is the JSON object returned by the server. +* `render` is the property of your entity to display in the typeahead input. This is used to render the initial +value(s) into the widget. Once a user starts typing, the rendered responses are dependent on the `route` or +`source` used. ### AJAX Response -The controller should return a `JSON` array in the following format. Note: `id` and `value` properties are required and -you may include any other properties that can potentially be used within the template. +The controller should return a `JSON` array in the following format. Note: `id` and `value` properties are required but +you may include other properties as well. ```javascript [ @@ -138,6 +155,42 @@ you may include any other properties that can potentially be used within the tem ] ``` +Note: If you are using a null `class` option then your JavaScript array should return an `id` and `value` that are +the same thing. e.g: + +```javascript +[ + { id: 'Result 1', value: 'Result 1' }, + { id: 'Result 2', value: 'Result 2' } +] +``` + +If you do not return the same string for the `id` and `value` you will get confusing results in your UI. + +### Custom Source Callback +Here is an example of a custom `source` callback. This example mimics the same result if you had used the `route` option. + +```javascript +function get_users(query, process) { + $.post('/mysite/lookup/users', {query: query}, 'json') + .success(function (data) { + // must convert the data array into a flat list of strings. + // If your lookup function already returns a flat list, then $.map() is not needed. + process($.map(data, function(a){ + return a.value; + })); + }); +} +``` + +```php +$builder->add('user', 'entity_typeahead', array( + 'class' => 'MyBundle:User', + 'render' => 'fullname', + 'source' => 'get_users', // a function name in the global javascript "window" object +)); +``` + ### Template Your form template might look something like this _(The screenshots above used this template bit)_. @@ -153,5 +206,6 @@ Your form template might look something like this _(The screenshots above used t This bundle renders its form elements in standard Symfony style. You will have to override the form blocks to get the proper Bootstrap styles applied. I strongly suggest something like -[braincrafted/bootstrap-bundle](https://github.com/braincrafted/bootstrap-bundle) that will override the symfony form +[mopa/bootstrap-bundle](https://github.com/phiamo/MopaBootstrapBundle) that will override the Symfony form templates with proper Bootstrap versions automatically for you. + diff --git a/Resources/config/services.yml b/Resources/config/services.yml index 55d2d3e..61af546 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -13,10 +13,3 @@ services: class: "%lifo_typeahead.typeahead_type.class%" arguments: ["@service_container", "@doctrine.orm.entity_manager", "@router"] tags: [{ name: form.type, alias: entity_typeahead }] - - # experimental extension ... - #lifo_typeahead.form.form_extension: - # class: Lifo\TypeaheadBundle\Form\Extension\FormExtension - # arguments: [] - # tags: [{ name: form.type_extension, alias: form }] - \ No newline at end of file diff --git a/Resources/public/js/typeaheadbundle.js b/Resources/public/js/typeaheadbundle.js index ab57c41..e7734b1 100644 --- a/Resources/public/js/typeaheadbundle.js +++ b/Resources/public/js/typeaheadbundle.js @@ -9,7 +9,7 @@ * Override the base Typeahead object with new features. * Based on https://gist.github.com/ecmel/4365063 */ -!function($) { +!function ($) { var defs = $.fn.typeahead.defaults, base = $.fn.typeahead.Constructor.prototype; @@ -26,7 +26,7 @@ defs.resetOnSelect = false; // reset the input when an item is selected? defs.change = null; // onChange callback when $element is changed - defs.beforeSend = function(xhr, opts) { + defs.beforeSend = function (xhr, opts) { if (!this.options.spinner || this.$addon.data('prev-icon-class') != undefined) return; var icon = this.$addon.children().first(); if (icon.length >= 1) { @@ -35,7 +35,7 @@ } }; - defs.afterSend = function(xhr, status) { + defs.afterSend = function (xhr, status) { if (!this.options.spinner || this.$addon.data('prev-icon-class') == undefined) return; var icon = this.$addon.children().first(); if (icon.length >= 1) { @@ -45,7 +45,7 @@ } }; - defs.source = function(query, process) { + defs.source = function (query, process) { query = $.trim(query.toLowerCase()); if (query === '' || query.length < this.options.minLength) { @@ -70,10 +70,15 @@ context: this, url: this.options.url, type: 'post', - data: { query: query, limit: this.options.items }, + data: { + query: query, + limit: this.options.items, + property: this.$element.data('property'), + render: this.$element.data('render') + }, beforeSend: this.options.beforeSend, complete: this.options.afterSend, - success: function(data) { + success: function (data) { that.queries[query] = items = []; // clear cache for (var i = 0; i < data.length; i++) { if (data[i].value !== undefined && data[i].id !== undefined) { @@ -96,11 +101,11 @@ return null; }; - base.select = function() { + base.select = function () { var val = this.updater(this.$menu.find('.active').attr('data-value')); this.$element - .val(this.options.resetOnSelect ? '' : val) - .change(); + .val(this.options.resetOnSelect ? '' : val) + .change(); if ($.isFunction(this.options.change)) { this.options.change.apply(this, [val, this.ids[val]]); @@ -113,7 +118,7 @@ return this.hide(); }; - base.updater = function(item) { + base.updater = function (item) { // update value of related field if (this.$id && this.ids[item]) { this.$id.val(this.ids[item].id); @@ -141,7 +146,7 @@ return this._updater(item); }; - base.blur = function(e) { + base.blur = function (e) { // only call updater if a menu item was not selected. This prevents a // flicker of the original (orig) from showing up briefly when user // selects an item from the menu. @@ -151,16 +156,18 @@ this._blur(e); }; - base.lookup = function() { + base.lookup = function () { if (this.options.delay) { clearTimeout(this.delayedLookup); - this.delayedLookup = setTimeout($.proxy(function(){ this._lookup() }, this), this.options.delay); + this.delayedLookup = setTimeout($.proxy(function () { + this._lookup() + }, this), this.options.delay); } else { this._lookup(); } }; - base.listen = function() { + base.listen = function () { this._listen(); this.ids = {}; @@ -168,68 +175,68 @@ // save original value when page was loaded if (this.orig === undefined) { - this.orig = { value: this.$element.val() }; + this.orig = {value: this.$element.val()}; } // maintain relationship with another element that will hold the // selected ID (usually a hidden input). if (this.options.id) { - this.$id = $('#' + this.options.id.replace(/(:|\.|\[|\])/g, '\\$1')); + this.$id = $('#' + this.options.id.replace(/(:|\.|\[|])/g, '\\$1')); if (this.$element.val() != '') { - this.ids[this.$element.val()] = { id: this.$id.val(), value: this.$element.val() }; + this.ids[this.$element.val()] = {id: this.$id.val(), value: this.$element.val()}; } this.orig.id = this.$id.val(); } // handle pasting via mouse this.$element - //.on('contextmenu', $.proxy(this.on_contextmenu, this)) .on('paste', $.proxy(this.on_paste, this)); // any "addon" icons? this.$addon = this.$element.siblings('.input-group-addon'); }; - base.on_paste = function(e) { + base.on_paste = function () { // since the pasted text has not actually been updated in the input // when this event fires we have to put a very small delay before // triggering a new lookup or else it'll simply do the lookup with // the current text in the input. clearTimeout(this.pasted); - this.pasted = setTimeout($.proxy(function(){ this.lookup(); this.pasted = undefined; }, this), 100); + this.pasted = setTimeout($.proxy(function () { + this.lookup(); + this.pasted = undefined; + }, this), 100); }; - - // convienence method to auto-select the input text; might not actually - // be wanted in all cases but for now I want it... - //base.on_contextmenu = function(e) { - // this.$element.select(); - //} }(jQuery); -!function($) { - $(function(){ - // The controller handling the request already filtered the items and +!function ($) { + $(function () { + // The controller handling the request already have filtered the items and // its possible it matched things that are not in the displayed label so // we must return true for all. - var matcher = function(){ return true }; + var matcher = function () { + return true + }; + + // callback when the $element is changed. + var change = function (text, data) { + var _id = this.$id.attr('id'), + list = $('#' + _id + '_list'), + formName = this.$element.closest('form').prop('name'); - // callback when the $element is changed. Gives our customization a - // chance to act on the new data. - var change = function(text, data){ - var _id = this.$id.attr('id'); - var list = $('#' + _id + '_list'); if (list.length) { var li = list.find('#' + _id + '_' + data.id); if (!li.length) { - // convert 'name_subname_extraname' to 'name[subname][extraname][]' - var name = _id.split(/_/); + // convert 'formname_subname_subname' to 'formname[subname][subname][]' + // "formname" can safely have underscores + var name = (formName ? _id.replace(formName + '_', '') : _id).split('_'); + if (formName) name.unshift(formName); name = (name.length > 1 ? name.shift() + '[' + name.join('][') + ']' : name.join()) + '[]'; - li = $( this.$id.data('prototype') ); + li = $(this.$id.data('prototype')); li.data('value', data.id) .find('input:hidden').val(data.id).attr('id', _id + '_' + data.id).attr('name', name).end() .find('.lifo-typeahead-item').text(text).end() - .appendTo(list) - ; + .appendTo(list); } } @@ -238,7 +245,7 @@ } }; - var typeahead = function(e){ + var typeahead = function () { var me = $(this); if (me.data('typeahead')) return; @@ -246,6 +253,7 @@ opts = { id: me.attr('id').replace(/_text$/, ''), url: d.url, + source: d.source, change: change, matcher: matcher }; @@ -253,7 +261,7 @@ if (undefined !== d.items && d.items != '') opts.items = d.items; if (undefined !== d.spinner) opts.spinner = d.spinner; if (undefined !== d.minlength && d.minlength != '') opts.minLength = d.minlength; - if (undefined !== d.resetonselect && d.resetonselect != '') opts.resetOnSelect = d.resetonselect ? true : false; + if (undefined !== d.resetonselect && d.resetonselect != '') opts.resetOnSelect = d.resetonselect; if (undefined !== d.callback && d.callback != '') opts.callback = d.callback; // allow the defined callback to be a function string @@ -263,6 +271,15 @@ opts.callback = window[opts.callback]; } + if (typeof opts.source == 'string' + && opts.source in window + && $.isFunction(window[opts.source])) { + opts.source = window[opts.source]; + } else { + opts.source = undefined; + } + + me.typeahead(opts); var list = $('#' + me.data('typeahead').$id.attr('id') + '_list'); @@ -279,7 +296,7 @@ // on-click handler to remove items from