Last active
August 1, 2017 18:42
-
-
Save markdboyd/c9d2543bfa311a234c566e87871a16db to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
diff --git a/geolocation.libraries.yml b/geolocation.libraries.yml | |
index 71c2fcf..b5dfaaf 100644 | |
--- a/geolocation.libraries.yml | |
+++ b/geolocation.libraries.yml | |
@@ -22,6 +22,12 @@ geolocation.views.filter.geocoder: | |
dependencies: | |
- geolocation/geolocation.geocoder | |
+# HTML5 Geolocation API. | |
+geolocation.html5: | |
+ version: 1.x | |
+ js: | |
+ js/geolocation-html5.js: { scope: footer } | |
+ | |
# HTML5 widget library. | |
geolocation.widgets.html5: | |
version: 1.x | |
@@ -31,6 +37,17 @@ geolocation.widgets.html5: | |
js: | |
js/geolocation-widget-html5.js: { scope: footer } | |
+# HTML5 proximity field. | |
+geolocation.proximity.html5: | |
+ version: 1.x | |
+ js: | |
+ js/geolocation-proximity-html5.js: { scope: footer } | |
+ dependencies: | |
+ - core/drupal.ajax | |
+ - views/views.ajax | |
+ - geolocation/geolocation.googlemapsapi | |
+ - geolocation/geolocation.html5 | |
+ | |
# | |
# | |
# Google Maps API | |
diff --git a/geolocation.services.yml b/geolocation.services.yml | |
index 871d477..768a2ee 100644 | |
--- a/geolocation.services.yml | |
+++ b/geolocation.services.yml | |
@@ -11,3 +11,6 @@ services: | |
plugin.manager.geolocation.geocoder: | |
class: Drupal\geolocation\GeocoderManager | |
parent: default_plugin_manager | |
+ | |
+ geolocation.temp_store: | |
+ class: Drupal\geolocation\GeolocationTempStore | |
diff --git a/js/geolocation-common-map.js b/js/geolocation-common-map.js | |
index 0b14d3f..961b19f 100644 | |
--- a/js/geolocation-common-map.js | |
+++ b/js/geolocation-common-map.js | |
@@ -495,7 +495,11 @@ | |
markerClustererStyles = commonMapSettings.markerClusterer.styles; | |
} | |
- new MarkerClusterer( | |
+ if (geolocationMap.markerClusterer) { | |
+ geolocationMap.markerClusterer.clearMarkers(); | |
+ } | |
+ | |
+ geolocationMap.markerClusterer = new MarkerClusterer( | |
geolocationMap.googleMap, | |
geolocationMap.mapMarkers, | |
{ | |
diff --git a/js/geolocation-google-maps-api.js b/js/geolocation-google-maps-api.js | |
index 69e0be4..a63d66a 100644 | |
--- a/js/geolocation-google-maps-api.js | |
+++ b/js/geolocation-google-maps-api.js | |
@@ -434,7 +434,6 @@ | |
*/ | |
Drupal.geolocation.removeMapMarker = function (map) { | |
map.mapMarkers = map.mapMarkers || []; | |
- | |
$.each( | |
map.mapMarkers, | |
diff --git a/js/geolocation-html5.js b/js/geolocation-html5.js | |
new file mode 100644 | |
index 0000000..b9fdf67 | |
--- /dev/null | |
+++ b/js/geolocation-html5.js | |
@@ -0,0 +1,106 @@ | |
+/** | |
+ * @file | |
+ * Javascript for integrating the W3C Geolocation API. | |
+ */ | |
+ | |
+(function ($, Drupal, drupalSettings, navigator) { | |
+ | |
+ 'use strict'; | |
+ | |
+ /** | |
+ * @namespace | |
+ */ | |
+ Drupal.geolocation = Drupal.geolocation || {}; | |
+ Drupal.geolocation.html5 = Drupal.geolocation.html5 || {}; | |
+ | |
+ drupalSettings.geolocation.html5 = drupalSettings.geolocation.html5 || {}; | |
+ | |
+ /** | |
+ * Adds a callback that will be called when client location is retrieved. | |
+ * | |
+ * @callback {geolocationHTML5ResultCallback} callback - The callback | |
+ */ | |
+ Drupal.geolocation.html5.addResultCallback = function (callback) { | |
+ Drupal.geolocation.html5.resultCallbacks = Drupal.geolocation.html5.resultCallbacks || []; | |
+ Drupal.geolocation.html5.resultCallbacks.push({callback: callback}); | |
+ }; | |
+ | |
+ /** | |
+ * Get the client location using HTML5 Geolocation API. | |
+ * | |
+ * @callback success - The success callback | |
+ * @callback error - The error callback | |
+ * @param {object} options - The options for the HTML5 geolocation | |
+ */ | |
+ Drupal.geolocation.html5.getClientLocation = function(success, error, options) { | |
+ if (typeof success === 'undefined' || !success) { | |
+ alert(Drupal.t('You must provide a success handler for the W3C Geolocation API.')); | |
+ return; | |
+ } | |
+ | |
+ var error = (error && typeof error === 'function') ? error : Drupal.geolocation.html5.getClientLocationError; | |
+ var options = options || { | |
+ enableHighAccuracy: true, | |
+ timeout: 5000, | |
+ maximumAge: 6000 | |
+ }; | |
+ | |
+ // If the browser supports W3C Geolocation API. | |
+ if (navigator.geolocation) { | |
+ // Get the geolocation from the browser. | |
+ navigator.geolocation.getCurrentPosition(success, error, options); | |
+ } | |
+ else { | |
+ alert(Drupal.t('No location data found. Your browser does not support the W3C Geolocation API.')); | |
+ } | |
+ }; | |
+ | |
+ /** | |
+ * Default error handler for Geolocation API. | |
+ * | |
+ * @param {object} error - The error object. | |
+ */ | |
+ Drupal.geolocation.html5.getClientLocationError = function(error) { | |
+ // Alert with error message. | |
+ switch (error.code) { | |
+ case error.PERMISSION_DENIED: | |
+ console.log(Drupal.t('No location data found. Reason: PERMISSION_DENIED.')); | |
+ drupalSettings.geolocation.html5.permissionDenied = true; | |
+ break; | |
+ case error.POSITION_UNAVAILABLE: | |
+ console.log(Drupal.t('No location data found. Reason: POSITION_UNAVAILABLE.')); | |
+ break; | |
+ case error.TIMEOUT: | |
+ console.log(Drupal.t('No location data found. Reason: TIMEOUT.')); | |
+ break; | |
+ default: | |
+ console.log(Drupal.t('No location data found. Reason: Unknown error.')); | |
+ break; | |
+ } | |
+ }; | |
+ | |
+ /** | |
+ * Attach HTML5 geolocation functionality. | |
+ * | |
+ * @type {Drupal~behavior} | |
+ */ | |
+ Drupal.behaviors.geolocationHTML5 = { | |
+ attach: function (context) { | |
+ if (!drupalSettings.geolocation.html5.retrieved && | |
+ !drupalSettings.geolocation.html5.permissionDenied) { | |
+ // Get the client location using the Geolocation API. | |
+ Drupal.geolocation.html5.getClientLocation(function(position) { | |
+ // Save the state of client location retrieval. | |
+ drupalSettings.geolocation.html5.retrieved = true; | |
+ | |
+ // Iterate over callbacks and call them with the geolocation result. | |
+ Drupal.geolocation.html5.resultCallbacks = Drupal.geolocation.html5.resultCallbacks || []; | |
+ $.each(Drupal.geolocation.html5.resultCallbacks, function (index, callbackContainer) { | |
+ callbackContainer.callback(position); | |
+ }); | |
+ }); | |
+ } | |
+ }, | |
+ }; | |
+ | |
+})(jQuery, Drupal, drupalSettings, navigator); | |
diff --git a/js/geolocation-proximity-html5.js b/js/geolocation-proximity-html5.js | |
new file mode 100644 | |
index 0000000..2fc2ccd | |
--- /dev/null | |
+++ b/js/geolocation-proximity-html5.js | |
@@ -0,0 +1,75 @@ | |
+(function ($, Drupal, drupalSettings) { | |
+ 'use strict'; | |
+ | |
+ /** | |
+ * @namespace | |
+ */ | |
+ drupalSettings.geolocation.html5 = drupalSettings.geolocation.html5 || {}; | |
+ drupalSettings.geolocation.html5.proximity_view_ids = drupalSettings.geolocation.html5.proximity_view_ids || []; | |
+ | |
+ Drupal.geolocation = Drupal.geolocation || {}; | |
+ Drupal.geolocation.proximityHTML5 = Drupal.geolocation.proximityHTML5 || {}; | |
+ | |
+ Drupal.geolocation.proximityHTML5.refreshView = function(mapId, coordinates, context) { | |
+ // Get the AJAX settings for this view. | |
+ var viewSettings = Drupal.views.instances['views_dom_id:' + mapId]; | |
+ var geolocationAjaxSettings = viewSettings.element_settings; | |
+ | |
+ // Change the progress indicator. | |
+ geolocationAjaxSettings.progress.type = 'throbber'; | |
+ | |
+ // Add the coordinates to the data to be submitted with the | |
+ // request. | |
+ geolocationAjaxSettings.submit['proximity_lat'] = coordinates.latitude; | |
+ geolocationAjaxSettings.submit['proximity_lng'] = coordinates.longitude; | |
+ | |
+ // Use AJAX to refresh the view. | |
+ $('.js-view-dom-id-' + mapId, context).trigger('RefreshView'); | |
+ }; | |
+ | |
+ Drupal.behaviors.geolocationProximityHTML5 = { | |
+ attach: function(context) { | |
+ if (!drupalSettings.geolocation.html5.proximity_view_ids.length || | |
+ drupalSettings.geolocation.html5.permissionDenied || | |
+ drupalSettings.geolocation.html5.has_coordinates) { | |
+ return; | |
+ } | |
+ | |
+ $.each(drupalSettings.geolocation.html5.proximity_view_ids, function(key, dom_id) { | |
+ Drupal.geolocation.proximityHTML5[dom_id] = Drupal.geolocation.proximityHTML5[dom_id] || {}; | |
+ Drupal.geolocation.proximityHTML5[dom_id].mapLoaded = $.Deferred(); | |
+ Drupal.geolocation.proximityHTML5[dom_id].receivedCoordinates = $.Deferred(); | |
+ | |
+ // Resolve map loaded promise immediately if there is no map | |
+ // for this view DOM ID. Otherwise, wait until the map is loaded. | |
+ if (!drupalSettings.geolocation.commonMap || | |
+ !drupalSettings.geolocation.commonMap[dom_id]) { | |
+ Drupal.geolocation.proximityHTML5[dom_id].mapLoaded.resolve(); | |
+ } | |
+ else { | |
+ Drupal.geolocation.addMapLoadedCallback(function (map) { | |
+ Drupal.geolocation.proximityHTML5[dom_id].mapLoaded.resolve(map); | |
+ }, dom_id); | |
+ } | |
+ | |
+ Drupal.geolocation.html5.addResultCallback(function (position) { | |
+ // If specified, auto refresh the view with the received | |
+ // HTML5 coordinates. | |
+ if (drupalSettings.geolocation.html5[dom_id] && | |
+ drupalSettings.geolocation.html5[dom_id].auto_refresh) { | |
+ Drupal.geolocation.proximityHTML5[dom_id].receivedCoordinates.resolve(position); | |
+ } | |
+ }); | |
+ | |
+ // Wait for promises to be resolved for the map to be loaded and | |
+ // coordinates to be received before acting on the map. | |
+ $.when( | |
+ Drupal.geolocation.proximityHTML5[dom_id].mapLoaded, | |
+ Drupal.geolocation.proximityHTML5[dom_id].receivedCoordinates | |
+ ).then(function(map, position) { | |
+ Drupal.geolocation.proximityHTML5.refreshView(dom_id, position.coords, context); | |
+ }); | |
+ }); | |
+ } | |
+ }; | |
+})(jQuery, Drupal, drupalSettings); | |
\ No newline at end of file | |
diff --git a/js/geolocation-widget-html5.js b/js/geolocation-widget-html5.js | |
index 32f3272..42442d3 100644 | |
--- a/js/geolocation-widget-html5.js | |
+++ b/js/geolocation-widget-html5.js | |
@@ -15,7 +15,7 @@ | |
* @prop {Drupal~behaviorAttach} attach | |
* Attaches html5 widget functionality to relevant elements. | |
*/ | |
- Drupal.behaviors.geolocationHTML5 = { | |
+ Drupal.behaviors.geolocationWidgetHTML5 = { | |
attach: function (context, settings) { | |
$('.geolocation-html5-button:not(.disabled)').each(function (index) { | |
// The parent element. | |
diff --git a/src/GeolocationTempStore.php b/src/GeolocationTempStore.php | |
new file mode 100644 | |
index 0000000..92c6892 | |
--- /dev/null | |
+++ b/src/GeolocationTempStore.php | |
@@ -0,0 +1,81 @@ | |
+<?php | |
+ | |
+namespace Drupal\geolocation; | |
+ | |
+/** | |
+ * A service to store and retrieve geolocation data across HTTP requests. | |
+ */ | |
+class GeolocationTempStore { | |
+ | |
+ /** | |
+ * An array of the client's location coordinates. | |
+ * | |
+ * @var array $client_location | |
+ */ | |
+ protected static $client_location; | |
+ | |
+ /** | |
+ * Store the client location for use across HTTP requests in the SESSION. | |
+ * | |
+ * @TODO: Integrate with TempStore. See https://www.drupal.org/node/2743931. | |
+ * | |
+ * @param string $latitude | |
+ * The client's latitude coordinate. | |
+ * @param string $longitude | |
+ * The client's longitude coordinate. | |
+ */ | |
+ public function setClientLocation($latitude, $longitude) { | |
+ if (empty($latitude) || empty($longitude)) { | |
+ return; | |
+ } | |
+ setCookie('client_location', "{$latitude}:{$longitude}", 0, '/', \Drupal::request()->getHost(), FALSE, TRUE); | |
+ } | |
+ | |
+ /** | |
+ * Get the client location. | |
+ * | |
+ * @return bool|array | |
+ * Return FALSE if no client location cookie exists, else return the array | |
+ * of the geolocation data. | |
+ */ | |
+ public function getClientLocation() { | |
+ if (isset(self::$client_location)) { | |
+ return self::$client_location; | |
+ } | |
+ $client_location = []; | |
+ $request = \Drupal::request(); | |
+ $stored_client_location = $request->cookies->get('client_location'); | |
+ $latitude_param = $request->get('proximity_lat', ''); | |
+ $longitude_param = $request->get('proximity_lng', ''); | |
+ if (!empty($stored_client_location)) { | |
+ list($latitude, $longitude) = explode(':', $stored_client_location); | |
+ // If we have coordinate parameters and they are not the same as the | |
+ // stored geolocation coordinates, then use the coordinate parameter | |
+ // values and store them. | |
+ if ((!empty($latitude_param) && !empty($longitude_param)) && | |
+ ($latitude !== $latitude_param || $longitude !== $longitude_param)) { | |
+ $latitude = $latitude_param; | |
+ $longitude = $longitude_param; | |
+ // Store the geolocation overrides. | |
+ $this->setClientLocation($latitude, $longitude); | |
+ } | |
+ } | |
+ else { | |
+ $latitude = $latitude_param; | |
+ $longitude = $longitude_param; | |
+ // Store the new geolocation coordinates. | |
+ $this->setClientLocation($latitude, $longitude); | |
+ } | |
+ if (!empty($latitude) && !empty($longitude)) { | |
+ $client_location = [ | |
+ 'lat' => $latitude, | |
+ 'lng' => $longitude, | |
+ ]; | |
+ } | |
+ if (!isset(self::$client_location)) { | |
+ self::$client_location = $client_location; | |
+ } | |
+ return $client_location; | |
+ } | |
+ | |
+} | |
\ No newline at end of file | |
diff --git a/src/Plugin/views/field/ProximityField.php b/src/Plugin/views/field/ProximityField.php | |
index d473cf7..90170b3 100644 | |
--- a/src/Plugin/views/field/ProximityField.php | |
+++ b/src/Plugin/views/field/ProximityField.php | |
@@ -3,6 +3,8 @@ | |
namespace Drupal\geolocation\Plugin\views\field; | |
use Drupal\geolocation\GeolocationCore; | |
+use Drupal\geolocation\GeolocationTempStore; | |
+use Drupal\views\Plugin\views\exposed_form\InputRequired; | |
use Drupal\views\ResultRow; | |
use Drupal\views\Plugin\views\field\NumericField; | |
use Drupal\Core\Render\Element; | |
@@ -11,7 +13,7 @@ use Drupal\Core\Plugin\ContainerFactoryPluginInterface; | |
use Symfony\Component\DependencyInjection\ContainerInterface; | |
/** | |
- * Field handler for geolocaiton field. | |
+ * Field handler for geolocation field. | |
* | |
* @ingroup views_field_handlers | |
* | |
@@ -27,6 +29,11 @@ class ProximityField extends NumericField implements ContainerFactoryPluginInter | |
protected $geolocationCore; | |
/** | |
+ * @var \Drupal\geolocation\GeolocationTempStore | |
+ */ | |
+ protected $tempStore; | |
+ | |
+ /** | |
* Constructs a Handler object. | |
* | |
* @param array $configuration | |
@@ -37,11 +44,14 @@ class ProximityField extends NumericField implements ContainerFactoryPluginInter | |
* The plugin implementation definition. | |
* @param \Drupal\geolocation\GeolocationCore $geolocation_core | |
* The GeolocationCore object. | |
+ * @param \Drupal\geolocation\GeolocationTempStore $temp_store | |
+ * The geolocation private tempstore. | |
*/ | |
- public function __construct(array $configuration, $plugin_id, $plugin_definition, GeolocationCore $geolocation_core) { | |
+ public function __construct(array $configuration, $plugin_id, $plugin_definition, GeolocationCore $geolocation_core, GeolocationTempStore $temp_store) { | |
parent::__construct($configuration, $plugin_id, $plugin_definition); | |
$this->geolocationCore = $geolocation_core; | |
+ $this->tempStore = $temp_store; | |
} | |
/** | |
@@ -55,7 +65,8 @@ class ProximityField extends NumericField implements ContainerFactoryPluginInter | |
$configuration, | |
$plugin_id, | |
$plugin_definition, | |
- $geolocation_core | |
+ $geolocation_core, | |
+ $container->get('geolocation.temp_store') | |
); | |
} | |
@@ -102,6 +113,7 @@ class ProximityField extends NumericField implements ContainerFactoryPluginInter | |
'#options' => [ | |
'direct_input' => $this->t('Static Values'), | |
'user_input' => $this->t('User input'), | |
+ 'client_location' => $this->t('Client location (HTML5)'), | |
], | |
]; | |
@@ -154,6 +166,8 @@ class ProximityField extends NumericField implements ContainerFactoryPluginInter | |
['select[name="options[proximity_source]"]' => ['value' => 'entity_id_argument']], | |
'or', | |
['select[name="options[proximity_source]"]' => ['value' => 'user_input']], | |
+ 'or', | |
+ ['select[name="options[proximity_source]"]' => ['value' => 'client_location']], | |
], | |
], | |
], | |
@@ -378,6 +392,13 @@ class ProximityField extends NumericField implements ContainerFactoryPluginInter | |
$units = $this->options['proximity_units']; | |
break; | |
+ case 'client_location': | |
+ $geolocation = $this->tempStore->getClientLocation(); | |
+ $latitude = !empty($geolocation['lat']) ? $geolocation['lat'] : ''; | |
+ $longitude = !empty($geolocation['lng']) ? $geolocation['lng'] : ''; | |
+ $units = $this->options['proximity_units']; | |
+ break; | |
+ | |
case 'filter': | |
/** @var \Drupal\geolocation\Plugin\views\filter\ProximityFilter $filter */ | |
$filter = $this->view->filter[$this->options['proximity_filter']]; | |
@@ -473,29 +494,35 @@ class ProximityField extends NumericField implements ContainerFactoryPluginInter | |
* The current state of the form. | |
*/ | |
public function viewsForm(array &$form, FormStateInterface $form_state) { | |
- if ($this->options['proximity_source'] != 'user_input') { | |
+ if (!in_array($this->options['proximity_source'], ['user_input', 'client_location'])) { | |
unset($form['actions']); | |
return; | |
} | |
+ | |
+ $proximity_source = $this->options['proximity_source']; | |
+ | |
$form['#cache']['max-age'] = 0; | |
$form['#method'] = 'GET'; | |
$form['#attributes']['class'][] = 'geolocation-views-proximity-field'; | |
+ $proximity_lat = $this->view->getRequest()->get('proximity_lat', ''); | |
$form['proximity_lat'] = [ | |
'#type' => 'textfield', | |
'#title' => $this->t('Latitude'), | |
'#empty_value' => '', | |
- '#default_value' => $this->view->getRequest()->get('proximity_lat', ''), | |
+ '#default_value' => $proximity_lat, | |
'#maxlength' => 255, | |
'#weight' => -1, | |
]; | |
+ | |
+ $proximity_lng = $this->view->getRequest()->get('proximity_lng', ''); | |
$form['proximity_lng'] = [ | |
'#type' => 'textfield', | |
'#title' => $this->t('Longitude'), | |
'#empty_value' => '', | |
- '#default_value' => $this->view->getRequest()->get('proximity_lng', ''), | |
+ '#default_value' => $proximity_lng, | |
'#maxlength' => 255, | |
'#weight' => -1, | |
]; | |
@@ -503,6 +530,7 @@ class ProximityField extends NumericField implements ContainerFactoryPluginInter | |
if ( | |
$this->options['proximity_geocoder'] | |
&& !empty($this->options['proximity_geocoder_plugin_settings']) | |
+ && $proximity_source !== 'client_location' | |
) { | |
$geocoder_configuration = $this->options['proximity_geocoder_plugin_settings']['settings']; | |
$geocoder_configuration['label'] = $this->t('Address'); | |
@@ -533,6 +561,64 @@ class ProximityField extends NumericField implements ContainerFactoryPluginInter | |
$form['actions']['submit']['#value'] = $this->t('Calculate proximity'); | |
+ // Add functionality for retrieving location from client | |
+ // using HTML5 geolocation API. | |
+ if ($proximity_source === 'client_location') { | |
+ // Determine if we already have coordinate data, either retrieved from the | |
+ // current request or stored in a cookie. | |
+ $has_coordinates = ((!empty($proximity_lat) && !empty($proximity_lng)) || | |
+ (!empty($this->tempStore->getClientLocation()))); | |
+ | |
+ // If AJAX is not enabled on this view, then enable it and run | |
+ // Views pre-render code to generate the AJAX settings for the | |
+ // view. These AJAX settings are used by the HTML5 geolocation | |
+ // code to auto-update the view after the HTML5 client location | |
+ // is retrieved. | |
+ if (!$this->view->ajaxEnabled() && !$has_coordinates) { | |
+ $this->view->setAjaxEnabled(TRUE); | |
+ views_views_pre_render($this->view); | |
+ } | |
+ | |
+ // By default, the view will auto-refresh using AJAX once HTML5 | |
+ // coordinates are received. | |
+ $auto_refresh = true; | |
+ | |
+ // Determine if this view requires exposed form input and if any | |
+ // exposed input exists. If the exposed form requires input and | |
+ // none exists, then the view will not be auto-refreshed via | |
+ // AJAX. | |
+ $exposed_form_plugin = $this->view->display_handler->getPlugin('exposed_form'); | |
+ if ($exposed_form_plugin instanceof InputRequired) { | |
+ $auto_refresh = (!empty($this->view->getExposedInput())); | |
+ } | |
+ | |
+ // Add assets and drupalSettings for HTML5 geolocation. | |
+ $form = array_merge_recursive($form, [ | |
+ '#attached' => [ | |
+ 'library' => [ | |
+ 'geolocation/geolocation.proximity.html5', | |
+ ], | |
+ 'drupalSettings' => [ | |
+ 'geolocation' => [ | |
+ 'html5' => [ | |
+ 'has_coordinates' => $has_coordinates, | |
+ 'proximity_view_ids' => [$this->view->dom_id], | |
+ $this->view->dom_id => [ | |
+ 'auto_refresh' => $auto_refresh, | |
+ ], | |
+ ], | |
+ ], | |
+ ], | |
+ ], | |
+ ]); | |
+ | |
+ // Remove the form inputs, as the view will be dynamically | |
+ // refreshed if HTML5 geolocation succeeds. | |
+ unset($form['proximity_lat']); | |
+ unset($form['proximity_lng']); | |
+ unset($form['actions']); | |
+ } | |
+ | |
// #weight will be stripped from 'output' in preRender callback. | |
// Offset negatively to compensate. | |
foreach (Element::children($form) as $key) { |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment