src/Plugin/Field/FieldWidget/LocalistUrlWidget.php
<?php
namespace Drupal\stanford_fields\Plugin\Field\FieldWidget;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Field\Attribute\FieldWidget;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element\Url as UrlElement;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Drupal\link\Plugin\Field\FieldWidget\LinkWidget;
use GuzzleHttp\ClientInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use GuzzleHttp\Promise\Utils;
/**
* Plugin implementation of the 'localist_url' widget.
*/
#[FieldWidget(
id: 'localist_url',
label: new TranslatableMarkup('Localist URL'),
field_types: ['link'],
)]
class LocalistUrlWidget extends LinkWidget {
/**
* Http Client Service.
*
* @var \GuzzleHttp\ClientInterface
*/
protected $client;
/**
* Caching service.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $cache;
/**
* API data from Localist.
*
* @var array
*/
protected $apiData = [];
/**
* {@inheritDoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$plugin_id,
$plugin_definition,
$configuration['field_definition'],
$configuration['settings'],
$configuration['third_party_settings'],
$container->get('http_client'),
$container->get('cache.default')
);
}
/**
* {@inheritDoc}
*/
public static function defaultSettings() {
$settings = [
'base_url' => '',
'select_distinct' => FALSE,
];
return $settings + parent::defaultSettings();
}
/**
* {@inheritDoc}
*/
public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, ClientInterface $client, CacheBackendInterface $cache) {
parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
$this->client = $client;
$this->cache = $cache;
}
/**
* {@inheritDoc}
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
$elements = parent::settingsForm($form, $form_state);
$elements['placeholder_url']['#access'] = FALSE;
$elements['placeholder_title']['#access'] = FALSE;
$elements['select_distinct'] = [
'#type' => 'checkbox',
'#title' => $this->t('Select Distinct'),
'#default_value' => $this->getSetting('select_distinct'),
];
$elements['base_url'] = [
'#type' => 'url',
'#title' => $this->t('Base localist domain'),
'#required' => TRUE,
'#default_value' => $this->getSetting('base_url'),
'#element_validate' => [
[UrlElement::class, 'validateUrl'],
[$this, 'validateUrl'],
],
];
return $elements;
}
/**
* Validate the given domain has a localist API response.
*
* @param array $element
* Url form element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* Current form state object.
* @param array $complete_form
* Complete form.
*/
public function validateUrl(array &$element, FormStateInterface $form_state, array &$complete_form) {
$input = NestedArray::getValue($form_state->getValues(), $element['#parents']);
if ($form_state::hasAnyErrors()) {
return;
}
try {
$response = $this->client->request('GET', '/api/2/events', ['base_uri' => $input]);
json_decode((string) $response->getBody(), TRUE, 512, JSON_THROW_ON_ERROR);
}
catch (\Throwable $e) {
$form_state->setError($element, $this->t('URL is not a Localist domain.'));
}
}
/**
* {@inheritDoc}
*/
public function settingsSummary() {
$summary = [];
if (empty($this->getSetting('base_url'))) {
$summary[] = $this->t('No Base URL Provided');
}
else {
$summary[] = $this->t('Base URL: @url', ['@url' => $this->getSetting('base_url')]);
}
return $summary;
}
/**
* {@inheritDoc}
*/
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
$element = parent::formElement($items, $delta, $element, $form, $form_state);
// Fallback to inherited link widget if the base_url is not set.
if (!$this->getSetting('base_url')) {
return $element;
}
try {
$this->getApiData();
}
catch (\Throwable $e) {
$this->messenger()
->addError('Unable to fetch data from the system to provide easy to use field options. Please try again later.');
return $element;
}
$element['uri']['#access'] = FALSE;
$element['title']['#access'] = FALSE;
$element['attributes']['#access'] = FALSE;
$item = $items[$delta];
$element['filters'] = [
'#type' => 'details',
'#title' => $this->t('Filters'),
'#open' => TRUE,
'#collapsible' => FALSE,
];
$query_parameters = [];
if ($item->uri) {
parse_str(parse_url(urldecode($item->uri), PHP_URL_QUERY), $query_parameters);
}
$element['filters']['group_id'] = $this->getGroups($query_parameters['group_id'] ?? NULL);
$element['filters']['venue_id'] = $this->getPlaces($query_parameters['venue_id'] ?? NULL);
$element['filters']['type'] = $this->getFilters($query_parameters['type'] ?? []);
$element['filters']['match'] = [
'#type' => 'select',
'#title' => $this->t('Content Must Match'),
'#default_value' => $query_parameters['match'] ?? NULL,
'#empty_option' => $this->t('At least one selected group or venue, and one selected filter item'),
'#options' => [
'any' => $this->t('Any selected group, venue, or filter item'),
'all' => $this->t('At least one selected group or venue, and all selected filter items'),
'or' => $this->t('Any selected group or venue, and one selected filter item'),
],
];
return $element;
}
/**
* {@inheritDoc}
*/
public function massageFormValues(array $values, array $form, FormStateInterface $form_state) {
if (!$this->getSetting('base_url')) {
return parent::massageFormValues($values, $form, $form_state);
}
foreach ($values as $delta => &$value) {
foreach ($value['filters'] as &$filter_values) {
if (is_array($filter_values)) {
$filter_values = self::flattenValues($filter_values);
}
}
$value['filters'] = array_filter($value['filters']);
if (empty($value['filters'])) {
unset($values[$delta]);
continue;
}
$value['filters']['days'] = '365';
$value['filters']['pp'] = '100';
// We may in the future have a configuration value
// to include the "distinct"key to our API call.
// This tries to find such a value,
// and applies the key if it finds it.
if ($this->getSetting('select_distinct')) {
$value['filters']['distinct'] = TRUE;
}
$value['uri'] = Url::fromUri(rtrim($this->getSetting('base_url'), '/') . '/api/2/events', ['query' => $value['filters']])
->toString();
}
return parent::massageFormValues($values, $form, $form_state);
}
/**
* Flatten a multidimensional array.
*
* @param array $array
* The array to flatten.
*
* @return array
* Flattened array.
*/
protected static function flattenValues(array $array): array {
$return = [];
array_walk_recursive($array, function ($a) use (&$return) {
$return[] = $a;
});
return $return;
}
/**
* Get the form element with the filters from localist.
*
* @param array $default_value
* Default value for the form elements.
*
* @return array
* Form element render array.
*/
protected function getFilters(array $default_value = []): array {
$element = [];
foreach ($this->apiData['events/filters'] ?? [] as $filter_key => $options) {
$filter_options = [];
foreach ($options as $option) {
$filter_options[$option['id']] = $option['name'];
}
asort($filter_options);
$element[$filter_key] = [
'#type' => 'select',
'#title' => $this->apiData['events/labels']['filters'][$filter_key],
'#multiple' => TRUE,
'#options' => $filter_options,
'#default_value' => array_intersect($default_value, array_keys($filter_options)),
'#chosen' => TRUE,
];
}
return $element;
}
/**
* Gets groups and departments.
*
* @param string|null $default_value
* Default value for the form elements.
*
* @return array
* Form element render array.
*/
protected function getGroups(?string $default_value = NULL): array {
$element = [
'#type' => 'select',
'#title' => $this->t('Departments/Groups'),
'#multiple' => FALSE,
'#options' => [],
'#empty_option' => 'Select one:',
'#default_value' => $default_value,
];
foreach ($this->apiData['groups'] ?? [] as $group) {
$element['#options'][$group['group']['id']] = $group['group']['name'];
}
foreach ($this->apiData['departments'] ?? [] as $department) {
$element['#options'][$department['department']['id']] = $department['department']['name'];
}
asort($element['#options']);
return $element;
}
/**
* Get the form element for the venues selection.
*
* @param string|null $default_value
* Default value for the form element.
*
* @return array
* Form element render array.
*/
protected function getPlaces(?string $default_value = NULL): array {
$element = [
'#type' => 'select',
'#title' => $this->t('Venues'),
'#multiple' => FALSE,
'#options' => [],
'#empty_option' => 'Select one:',
'#default_value' => $default_value,
];
foreach ($this->apiData['places'] ?? [] as $place) {
$element['#options'][$place['place']['id']] = $place['place']['name'];
}
return $element;
}
/**
* Get the data from the localist API.
*/
protected function getApiData() {
// Data was already fetched.
if ($this->apiData) {
return $this->apiData;
}
$base_url = $this->getSetting('base_url');
// Check for some cached data before we fetch it all again.
if ($cache = $this->cache->get("localist_api:$base_url")) {
$this->apiData = $cache->data['data'];
// If the cache is not expired, return it. Otherwise, we'll attempt to
// fetch from the API. Fallback is the old cached data.
if ($cache->data['expires'] > time()) {
return $this->apiData;
}
}
try {
$this->fetchApiData();
}
catch (\Throwable $e) {
if (!$this->apiData) {
throw $e;
}
}
}
/**
* Call the Localist API with various endpoints to gather all the data needed.
*
* @return array
* Keyed array of api data.
*/
protected function fetchApiData(): array {
$base_url = $this->getSetting('base_url');
$options = [
'timeout' => 5,
'base_uri' => $base_url,
'query' => ['pp' => 1],
];
$promises = [
'groups' => $this->client->requestAsync('GET', '/api/2/groups', $options),
'departments' => $this->client->requestAsync('GET', '/api/2/departments', $options),
'places' => $this->client->requestAsync('GET', '/api/2/places', $options),
'events/filters' => $this->client->requestAsync('GET', '/api/2/events/filters', $options),
'events/labels' => $this->client->requestAsync('GET', '/api/2/events/labels', $options),
];
$results = self::unwrapAsyncRequests($promises);
foreach ($results as $key => $response) {
if (empty($response['page']['total'])) {
$this->apiData[$key] = $response;
continue;
}
$this->apiData[$key] = $this->fetchPagedApiData($key, $response['page']['total']);
}
$this->cache->set("localist_api:$base_url", [
'data' => $this->apiData,
'expires' => time() + 60 * 60,
], Cache::PERMANENT, ['localist_api']);
return $this->apiData;
}
/**
* Given the endpoint and count, async fetch from the API all pages.
*
* @param string $endpoint
* Localist API Endpoint.
* @param int $total_count
* Total number of items to chunk up.
*
* @return array
* Indexed array of api data.
*/
protected function fetchPagedApiData($endpoint, $total_count): array {
$base_url = $this->getSetting('base_url');
$options = [
'timeout' => 5,
'base_uri' => $base_url,
'query' => ['pp' => 100],
];
$number_of_pages = ceil($total_count / 100);
for ($i = 1; $i <= $number_of_pages; $i++) {
$options['query']['page'] = $i;
$paged_data[$i] = $this->client->requestAsync('GET', '/api/2/' . $endpoint, $options);
}
$paged_data = self::unwrapAsyncRequests($paged_data);
$data = [];
foreach ($paged_data as $page) {
unset($page['page']);
$key = key($page);
$data = array_merge($data, $page[$key]);
}
return $data;
}
/**
* Unwrap async promises and decode their body data.
*
* @param \GuzzleHttp\Promise\PromiseInterface[] $promises
* Associative array of Guzzle promises.
*
* @return array
* Associative array of json decoded data.
*/
protected static function unwrapAsyncRequests(array $promises): array {
$promises = Utils::unwrap($promises);
/** @var \GuzzleHttp\Psr7\Response $response */
foreach ($promises as &$response) {
$response = json_decode((string) $response->getBody(), TRUE, 512, JSON_THROW_ON_ERROR);
}
return $promises;
}
}