diff --git a/CHANGELOG.md b/CHANGELOG.md index 55f843ded..0bf210a5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0-beta6] 2022-07-30 + +### Added + +- [Issue #3290929: Provide a farmOS map form element](https://www.drupal.org/project/farm/issues/3290929) +- [Issue #3290993: Add "Move asset" button next to the current location field](https://www.drupal.org/project/farm/issues/3290993) +- [Generate unique form IDs for quick forms #547](https://github.com/farmOS/farmOS/pull/547) + +### Security + +- Update Drupal core to 9.3.16 for [SA-CORE-2022-011](https://www.drupal.org/sa-core-2022-011). +- Update Drupal core to 9.3.19 for [SA-CORE-2022-012](https://www.drupal.org/sa-core-2022-012), [SA-CORE-2022-013](https://www.drupal.org/sa-core-2022-013), [SA-CORE-2022-014](https://www.drupal.org/sa-core-2022-014), and [SA-CORE-2022-015](https://www.drupal.org/sa-core-2022-015). + +## [2.0.0-beta5] 2022-06-02 + ### Changed - [Issue #3275161: Allow IMG tags in default text format](https://www.drupal.org/project/farm/issues/3275161) @@ -16,11 +31,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - [Do not check php-geos requirement in the update phase #526](https://github.com/farmOS/farmOS/pull/526) +- Patch entity_reference_revisions module to fix upstream issue [#3267304](https://www.drupal.org/project/entity_reference_revisions/issues/3267304). ### Security - Update Drupal core to 9.3.12 for [SA-CORE-2022-008](https://www.drupal.org/sa-core-2022-008) and [SA-CORE-2022-009](https://www.drupal.org/sa-core-2022-009). +- Update Drupal core to 9.3.14 for [SA-CORE-2022-010](https://www.drupal.org/sa-core-2022-010). ## [2.0.0-beta4] 2022-04-13 @@ -190,7 +207,8 @@ moving forward. Drupal 7, which required a complete refactor of the codebase. By comparison, updating from Drupal 9 to 10 will simply involve updating deprecated code. -[Unreleased]: https://github.com/farmOS/farmOS/compare/2.0.0-beta4...HEAD +[Unreleased]: https://github.com/farmOS/farmOS/compare/2.0.0-beta5...HEAD +[2.0.0-beta5]: https://github.com/farmOS/farmOS/releases/tag/2.0.0-beta5 [2.0.0-beta4]: https://github.com/farmOS/farmOS/releases/tag/2.0.0-beta4 [2.0.0-beta3]: https://github.com/farmOS/farmOS/releases/tag/2.0.0-beta3 [2.0.0-beta2]: https://github.com/farmOS/farmOS/releases/tag/2.0.0-beta2 diff --git a/README.md b/README.md index 9dbfd092c..a92d02818 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ [![Last commit](https://img.shields.io/github/last-commit/farmOS/farmOS.svg?style=flat)](https://github.com/farmOS/farmOS/commits) [![Docker](https://img.shields.io/docker/pulls/farmos/farmos.svg)](https://hub.docker.com/r/farmos/farmos/) [![Twitter](https://img.shields.io/twitter/follow/farmOSorg.svg?label=%40farmOSorg&style=flat)](https://twitter.com/farmOSorg) -[![Chat](https://img.shields.io/matrix/farmOS:matrix.org.svg)](https://riot.im/app/#/room/#farmOS:matrix.org) -[![Backers on Open Collective](https://opencollective.com/farmOS/backers/badge.svg)](#backers) +[![Chat](https://img.shields.io/matrix/farmOS:matrix.org.svg)](https://app.element.io/#/room/#farmOS:matrix.org) +[![Backers on Open Collective](https://opencollective.com/farmOS/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/farmOS/sponsors/badge.svg)](#sponsors) farmOS is a web-based application for farm management, planning, and record diff --git a/composer.json b/composer.json index 292d6e445..257ff9639 100644 --- a/composer.json +++ b/composer.json @@ -18,15 +18,15 @@ "require": { "cweagans/composer-patches": "^1.6", "drupal/admin_toolbar": "^2.4", - "drupal/core": "9.3.12", + "drupal/core": "9.3.20", "drupal/config_update": "^1.7", "drupal/csv_serialization": "^2.0", "drupal/date_popup": "^1.1", "drupal/entity": "1.3", "drupal/entity_browser": "^2.6", "drupal/entity_reference_integrity": "^1.1", - "drupal/entity_reference_revisions": "^1.8", - "drupal/entity_reference_validators": "^1.0@alpha", + "drupal/entity_reference_revisions": "1.9", + "drupal/entity_reference_validators": "^1.0@beta", "drupal/exif_orientation": "^1.1", "drupal/fraction": "^2.0", "drupal/geofield": "^1.33", @@ -34,7 +34,7 @@ "drupal/inline_entity_form": "^1.0@RC", "drupal/inspire_tree": "^1.0", "drupal/jsonapi_extras": "^3.15", - "drupal/jsonapi_schema": "1.0-beta1", + "drupal/jsonapi_schema": "1.0-beta2", "drupal/log": "^2.0.2", "drupal/migrate_plus": "^5.1", "drupal/migrate_tools": "^5.0", @@ -44,7 +44,7 @@ "drupal/token": "^1.7", "drupal/views_geojson": "^1.1", "drush/drush": "^10.3", - "ext-simplexml": "^7.4", + "ext-simplexml": "*", "phayes/geophp": "1.2" }, "extra": { diff --git a/composer.project.json b/composer.project.json index ab427c46d..d40083881 100644 --- a/composer.project.json +++ b/composer.project.json @@ -1,11 +1,11 @@ { "require": { "cweagans/composer-patches": "^1.7", - "drupal/core-composer-scaffold": "9.3.12" + "drupal/core-composer-scaffold": "9.3.20" }, "require-dev": { "brianium/paratest": "^4", - "drupal/core-dev": "9.3.12", + "drupal/core-dev": "9.3.20", "phpspec/prophecy-phpunit": "^2", "symfony/finder": "^4.0" }, diff --git a/docker/build-farmOS.sh b/docker/build-farmOS.sh index afebfcd09..38d2f9774 100644 --- a/docker/build-farmOS.sh +++ b/docker/build-farmOS.sh @@ -42,6 +42,7 @@ composer require farmos/farmos ${FARMOS_COMPOSER_VERSION} --no-install allowedPlugins=( "composer/installers" "cweagans/composer-patches" + "dealerdirect/phpcodesniffer-composer-installer" "drupal/core-composer-scaffold" "oomphinc/composer-installers-extender" "wikimedia/composer-merge-plugin" diff --git a/docs/development/module/maps.md b/docs/development/module/maps.md new file mode 100644 index 000000000..3089772d4 --- /dev/null +++ b/docs/development/module/maps.md @@ -0,0 +1,358 @@ +# Maps + +farmOS includes features for rendering and manipulating geometry data in +map-based UIs. + +It uses [farmOS-map](https://github.com/farmOS/farmOS-map), which is based on +the open-source [OpenLayers](https://openlayers.org/) project. This includes +tools for drawing and editing geometries, adding imagery and vector layers, and +a framework for writing custom behaviors. + +farmOS-map is maintained by the farmOS community as a standalone library for +common agricultural mapping needs. It is designed to be reusable in any +application with similar needs. It is not specific to or dependent on farmOS +itself. Rather, farmOS includes it as a dependency, and provides some helpful +wrappers for using it inside modules. This page describes how to use farmOS-map +in farmOS modules. + +For more information about the farmOS-map library itself and what it provides, +refer to the farmOS-map documentation on GitHub: + +[github.com/farmOS/farmOS-map](https://github.com/farmOS/farmOS-map) + +## Render element + +Maps can be embedded in pages as a `farm_map` type render element. + +```php +$build['mymap'] = [ + '#type' => 'farm_map', + '#map_type' => 'default', + '#map_settings' => [ + 'mysetting' => 'myvalue', + ], + '#behaviors' => [ + 'mybehavior', + ], +]; +``` + +**Properties:** + +- `#map_type` (optional) - See [Map types](#map-types). Defaults to `default`. +- `#map_settings` (optional) - An array of map settings, which will be passed + into the map instance's client-side JavaScript object, so they are available + in behavior JavaScript. +- `#behaviors` (optional) - See [Behaviors](#behaviors). Defaults to `[]` (but + behaviors may also be added by map types and render events). + +## Form element + +Editable maps can be embedded in forms with a `farm_map_input` type element. +These maps will have the drawing/editing controls enabled, allowing geometries +to be added/edited/deleted directly in the map. A default value can be used to +pre-populate the map with a geometry. A text field can be optionally displayed +beneath the map to show the raw geometry data (auto-updates during editing). + +**Example:** + +```php +$form['mymap'] = [ + '#type' => 'farm_map_input', + '#title' => t('My Geometry'), + '#map_type' => 'default', + '#map_settings' => [ + 'mysetting' => 'myvalue', + ], + '#behaviors' => [ + 'mybehavior', + ], + '#display_raw_geometry' => TRUE, + '#default_value' => 'POINT(-45.967095060886315 32.77503850904169)', +]; +``` + +**Properties:** + +- `#map_type` (same as render element, above) +- `#map_settings` (same as render element, above) +- `#behaviors` (same as render element, above) +- `#display_raw_geometry` (optional) - Whether to show a text field below the + map with the raw geometry value in Well-Known Text (WKT) format. Defaults to + `FALSE`. +- `#default_value` (optional) - The default geometry value to display in the + map initially, in Well-Known Text (WKT) format. This geometry will be + editable in the map unless `#disabled` is `TRUE`. + +## Map types + +farmOS modules can optionally define "map types", which are then referenced in +the `#map_type` property of the render and form elements. + +**This is optional and in most cases the `default` map type is sufficient.** + +Map types are used to define reusable map configurations with common +[behaviors](#behaviors). They can be targeted by [render event](#render-events) +subscribers to add/modify behavior in certain contexts. + +Map types are represented as Drupal config entities, installed via modules, +just like asset types, log types, flags, etc. + +A very simple example of a custom map type definition looks like this: + +`my_module/config/install/farm_map.map_type.mymaptype.yml` + +```yaml +langcode: en +status: true +dependencies: + enforced: + module: + - my_module +id: mymaptype +label: My Map Type +description: "My module's custom map type." +behaviors: { } +options: { } +``` + +**Properties** + +- `id` - A unique ID for the map type. This will be referenced in `#map_type`. +- `label` - A human-readable label for the map type. +- `description` - A human-readable description for the map type. +- `behaviors` - A list of [behaviors](#behaviors) to attach to maps of this + type by default. +- `options` - Default options that will be merged with `#map_settings` and + passed into `farmOS.map.create()`. See: + [github.com/farmOS/farmOS-map#creating-a-map](https://github.com/farmOS/farmOS-map#creating-a-map) + +## Behaviors + +The farmOS-map library uses the concept of "behaviors" to encapsulate common +and reusable sets of map behavior logic into JavaScript objects that can be +"attached" to map instances. + +Behaviors can be used to add layers to a map, add new buttons/controls, enable +OpenLayers interactions, connect maps with other elements of a page like forms, +etc. + +For general information about farmOS-map behaviors, see: +[github.com/farmOS/farmOS-map#adding-behaviors](https://github.com/farmOS/farmOS-map#adding-behaviors) + +Some behaviors that farmOS provides include: + +- `wkt` - Adds a vector layer to the map based on a Well-Known Text (WKT) + string. Edit controls can be optionally enabled to allow drawing, modifying, + moving, and deleting geometries within the map. This behavior is enabled + automatically in the `farm_map_input` form element, and when `wkt` is + included in `#map_settings.` +- `input` - Listens for changes to geometries in the map and copies them to a + form input (`textfield` or `hidden`) to be saved/manipulated server-side. + This behavior is enabled automatically in the `farm_map_input` form element. +- `popup` - Adds a popup interaction to the map, which appears when a geometry + feature is clicked. +- `asset_type_layers` - Adds asset geometry vector and cluster layers to a + map. This behavior is responsible for adding the "Locations" layers on the + farmOS dashboard map, the "Assets" and "Asset counts" layers to asset maps, + automatically zooming to visible geometries, and adding asset details to + popups when a geometry is clicked (depends on the `popup` behavior). + +### Providing behaviors + +Modules can provide their own behaviors with a couple of additional files. + +The behavior itself is represented as a Drupal config entity, which gets +installed as a YML config file during module installation. + +For example (replace `my_module` with the module name, and `mybehavior` with +the behavior name): + +`my_module/config/install/farm_map.behavior.mybehavior.yml` + +```yaml +langcode: en +status: true +dependencies: + enforced: + module: + - my_module +id: mybehavior +label: My Behavior +description: 'Adds my custom behavior logic.' +library: 'my_module/behavior_mybehavior' +settings: { } +``` + +The module must declare the behavior JavaScript file as a "library" so that +it can be included in the page(s) that need it. + +For example (replace `my_module` with the module name, and `mybehavior` with +the behavior name): + +`my_module/my_module.libraries.yml` + +```yaml +behavior_mybehavior: + js: + js/farmOS.map.behaviors.mybehavior.js: { } + dependencies: + - farm_map/farm_map +``` + +Finally, the behavior JavaScript file should have a path and filename that +matches the library definition. + +For example (replace `my_module` with the module name, and `mybehavior` with +the behavior name): + +`my_module/js/farmOS.map.behaviors.mybehavior.js` + +```js +(function () { + farmOS.map.behaviors.mybehavior = { + attach: function (instance) { + + // My custom behavior logic. + } + }; +}()); +``` + +The `instance` object represents the farmOS-map instance, and includes helper +methods for common needs (eg: `instance.addLayer()`), as well as direct access +to the OpenLayers map object at `instance.map`. + +For more information see: +[github.com/farmOS/farmOS-map](https://github.com/farmOS/farmOS-map) + +### Attaching behaviors + +Behaviors can be "attached" (enabled) in a map in a few different ways: + +- [Map types](#map-types) can include a list of default `behaviors`. +- The `#behaviors` property of the `farm_map` [render element](#render-element) + and `farm_map_input` [form element](#form-element) can add specific behaviors + to individual elements. +- A [render event](#render-events) subscriber can use the + `$event->addBehavior()` method. + +In all cases the behavior's `id` (as defined it its config entity YML) is used. + +### Behavior settings + +Some behaviors may require additional settings based on their context. Best +practice is to include these in the map settings so that they are available in +the behavior JavaScript in the following way: + +`const settings = instance.farmMapSettings.behaviors.mybehavior;` + +This can be accomplished in different ways, depending on how the behavior is +being attached to the map. + +[Map types](#map-types) can add behavior settings to their `options` property. +For example: + +```yaml +langcode: en +status: true +dependencies: + enforced: + module: + - my_module +id: mymaptype +label: My Map Type +description: "My module's custom map type." +behaviors: + - mybehavior +options: + behaviors: + mybehavior: + mysetting: True +``` + +Maps added as [render](#render-element) or [form](#form-element) elements can +add behavior settings in their `#map_settings` property. For example: + +```php +$build['mymap'] = [ + '#type' => 'farm_map', + '#map_settings' => [ + 'behaviors' => [ + 'mybehavior' => [ + 'mysetting' => TRUE, + ], + ], + ], + '#behaviors' => [ + 'mybehavior', + ], +]; +``` + +Behaviors that are added via [render event](#render-events) subscribers can add +settings at the same time: + +```php +$event->addBehavior('mybehavior', ['mysetting' => TRUE]); +``` + +All of the above approaches will make the settings available in the behavior +JavaScript in the same place. + +## Render events + +farmOS will trigger an event when a map is rendered. Modules can set up an +event subscriber to perform additional logic at that time, such as adding +behaviors. + +For example, to add a behavior to all maps in farmOS, add the following two +files (replace `my_module` with the module name, and `mybehavior` with the +behavior name): + +`my_module/my_module.services.yml` + +```yaml +services: + my_module_map_render_event_subscriber: + class: Drupal\my_module\EventSubscriber\MapRenderEventSubscriber + tags: + - { name: 'event_subscriber' } +``` + +`my_module/src/EventSubscriber/MapRenderEventSubscriber` + +```php + 'onMapRender', + ]; + } + + /** + * React to the MapRenderEvent. + * + * @param \Drupal\farm_map\Event\MapRenderEvent $event + * The MapRenderEvent. + */ + public function onMapRender(MapRenderEvent $event) { + $event->addBehavior('mybehavior'); + } + +} +``` diff --git a/docs/development/module/services.md b/docs/development/module/services.md index 23090f19f..d1f42f264 100644 --- a/docs/development/module/services.md +++ b/docs/development/module/services.md @@ -60,9 +60,10 @@ controls are respected. **Methods**: -`getInventory($asset, $measure = '', $units = '')` - Get inventory summaries +`getInventory($asset, $measure = '', $units = 0)` - Get inventory summaries for an asset. Returns an array of arrays with the following keys: `measure`, -`value`, `units`. This can be optionally filtered by `$measure` and `$units`. +`value`, `units`. This can be optionally filtered by `$measure` (string) +and `$units` (term ID). **Example usage**: @@ -70,8 +71,8 @@ for an asset. Returns an array of arrays with the following keys: `measure`, // Get summaries of all inventories for an asset. $all_inventory = \Drupal::service('asset.inventory')->getInventory($asset); -// Get the current inventory for a given measure and units. -$gallons_of_fertilizer = \Drupal::service('asset.inventory')->getInventory($asset, 'volume', 'gallons'); +// Get the current inventory for a given measure (string) and units (term id). +$gallons_of_fertilizer = \Drupal::service('asset.inventory')->getInventory($asset, 'volume', 123); ``` ## Field factory service diff --git a/docs/hosting/migration.md b/docs/hosting/migration.md index 21cb7e1e8..d26e512ec 100644 --- a/docs/hosting/migration.md +++ b/docs/hosting/migration.md @@ -126,9 +126,9 @@ farmOS 2.x in `/var/www/farmOS_2.x`, and both are configured to use `sites/default/files` for public files, and `sites/default/private` for private files, then copy the files as follows: - cp -rp /var/www/farmOS_1.x/sites/default/files /var/www/farmOS_2.x/sites/default/files/migrate + cp -rp /var/www/farmOS_1.x/sites/default/files /var/www/farmOS_2.x/web/sites/default/files/migrate - cp -rp /var/www/farmOS_1.x/sites/default/private/files /var/www/farmOS_2.x/sites/default/private/files/migrate + cp -rp /var/www/farmOS_1.x/sites/default/private/files /var/www/farmOS_2.x/web/sites/default/private/files/migrate The farmOS migration code will automatically move files from `files/migrate/*` to `files/*`. Only the files that it finds in the `{file_managed}` table will diff --git a/mkdocs.yml b/mkdocs.yml index ec71d2a4b..b6acd8efa 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,6 +37,7 @@ nav: - Quick forms: development/module/quick.md - Entities: development/module/entities.md - Fields: development/module/fields.md + - Maps: development/module/maps.md - OAuth: development/module/oauth.md - Roles: development/module/roles.md - Services: development/module/services.md diff --git a/modules/core/location/farm_location.base_fields.inc b/modules/core/location/farm_location.base_fields.inc index 73968ce4c..0d9079ed5 100644 --- a/modules/core/location/farm_location.base_fields.inc +++ b/modules/core/location/farm_location.base_fields.inc @@ -23,8 +23,15 @@ function farm_location_asset_base_fields() { 'multiple' => TRUE, 'computed' => AssetLocationItemList::class, 'hidden' => 'form', - 'weight' => [ - 'view' => 95, + 'view_display_options' => [ + 'label' => 'inline', + 'type' => 'asset_current_location', + 'settings' => [ + 'link' => TRUE, + 'render_without_location' => TRUE, + 'move_asset_button' => TRUE, + ], + 'weight' => 95, ], ]; $fields['location'] = \Drupal::service('farm_field.factory')->baseFieldDefinition($options); diff --git a/modules/core/location/src/Form/AssetMoveActionForm.php b/modules/core/location/src/Form/AssetMoveActionForm.php index dbc4bd016..a991ecb89 100644 --- a/modules/core/location/src/Form/AssetMoveActionForm.php +++ b/modules/core/location/src/Form/AssetMoveActionForm.php @@ -2,6 +2,7 @@ namespace Drupal\farm_location\Form; +use Drupal\asset\Entity\AssetInterface; use Drupal\Core\Datetime\DrupalDateTime; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\ConfirmFormBase; @@ -127,8 +128,27 @@ class AssetMoveActionForm extends ConfirmFormBase { * {@inheritdoc} */ public function buildForm(array $form, FormStateInterface $form_state) { + + // Check if asset IDs were provided in the asset query param. + $request = \Drupal::request(); + if ($asset_ids = $request->get('asset')) { + + // Wrap in an array, if necessary. + if (!is_array($asset_ids)) { + $asset_ids = [$asset_ids]; + } + + // Add each asset the user has view access to. + $this->entities = array_filter($this->entityTypeManager->getStorage('asset')->loadMultiple($asset_ids), function (AssetInterface $asset) { + return $asset->access('view', $this->user); + }); + } + // Else load entities from the tempStore state. + else { + $this->entities = $this->tempStore->get($this->user->id()); + } + $this->entityType = $this->entityTypeManager->getDefinition('asset'); - $this->entities = $this->tempStore->get($this->user->id()); if (empty($this->entityType) || empty($this->entities)) { return new RedirectResponse($this->getCancelUrl() ->setAbsolute() @@ -191,7 +211,7 @@ class AssetMoveActionForm extends ConfirmFormBase { // Load location assets. $locations = []; - $location_ids = array_column($form_state->getValue('location', []), 'target_id'); + $location_ids = array_column($form_state->getValue('location', []) ?? [], 'target_id'); if (!empty($location_ids)) { $locations = $this->entityTypeManager->getStorage('asset')->loadMultiple($location_ids); } diff --git a/modules/core/location/src/Plugin/Field/FieldFormatter/AssetCurrentLocationFormatter.php b/modules/core/location/src/Plugin/Field/FieldFormatter/AssetCurrentLocationFormatter.php new file mode 100644 index 000000000..0408a4895 --- /dev/null +++ b/modules/core/location/src/Plugin/Field/FieldFormatter/AssetCurrentLocationFormatter.php @@ -0,0 +1,114 @@ + FALSE, + 'move_asset_button' => FALSE, + ] + parent::defaultSettings(); + } + + /** + * {@inheritdoc} + */ + public function settingsForm(array $form, FormStateInterface $form_state) { + $elements = parent::settingsForm($form, $form_state); + $elements['render_without_location'] = [ + '#title' => $this->t('Render without location'), + '#description' => $this->t('Include this field when the asset has no current location.'), + '#type' => 'checkbox', + '#default_value' => $this->getSetting('render_without_location'), + ]; + $elements['move_asset_button'] = [ + '#title' => $this->t('Move asset button'), + '#description' => $this->t('Include a button to move the asset.'), + '#type' => 'checkbox', + '#default_value' => $this->getSetting('move_asset_button'), + ]; + return $elements; + } + + /** + * {@inheritdoc} + */ + public function settingsSummary() { + $summary = parent::settingsSummary(); + $summary[] = $this->getSetting('render_without_location') ? $this->t('Render without current location') : $this->t('Do not render without current location'); + $summary[] = $this->getSetting('move_asset_button') ? $this->t('Include move asset button') : $this->t('No move asset button'); + return $summary; + } + + /** + * {@inheritdoc} + */ + public function viewElements(FieldItemListInterface $items, $langcode) { + + // Build labels in parent. + $elements = parent::viewElements($items, $langcode); + + // Get the asset. + $asset = $items->getEntity(); + + // If the asset is fixed don't render additional information. + if ($asset->get('is_fixed')->value) { + return $elements; + } + + // If there are no current locations only render if configured to. + if (empty($elements) && !$this->getSetting('render_without_location')) { + return $elements; + } + + // Add N/A if there are no current locations. + if (empty($elements)) { + + // Render N/A if configured. + $elements[] = ['#markup' => 'N/A']; + } + + // Add the move asset button if configured. + if ($this->getSetting('move_asset_button')) { + + // Append a "Move asset" link. + $options = [ + 'query' => [ + 'asset' => $asset->id(), + 'destination' => $asset->toUrl()->toString(), + ], + ]; + $elements[] = [ + '#type' => 'link', + '#title' => $this->t('Move asset'), + '#url' => Url::fromRoute('farm_location.asset_move_action_form', [], $options), + '#attributes' => [ + 'class' => ['button', 'button--small'], + ], + ]; + } + return $elements; + } + +} diff --git a/modules/core/map/config/install/farm_map.map_behavior.geofield.yml b/modules/core/map/config/install/farm_map.map_behavior.geofield.yml deleted file mode 100644 index f4f0897ca..000000000 --- a/modules/core/map/config/install/farm_map.map_behavior.geofield.yml +++ /dev/null @@ -1,11 +0,0 @@ -langcode: en -status: true -dependencies: - enforced: - module: - - farm_map -id: geofield -label: Geofield -description: 'Copies map layer data into a form textarea.' -library: 'farm_map/behavior_geofield' -settings: { } diff --git a/modules/core/map/config/install/farm_map.map_behavior.input.yml b/modules/core/map/config/install/farm_map.map_behavior.input.yml new file mode 100644 index 000000000..df1acdd0b --- /dev/null +++ b/modules/core/map/config/install/farm_map.map_behavior.input.yml @@ -0,0 +1,11 @@ +langcode: en +status: true +dependencies: + enforced: + module: + - farm_map +id: input +label: Input +description: 'Syncs editable map layer data into a form input.' +library: 'farm_map/behavior_input' +settings: { } diff --git a/modules/core/map/config/install/farm_map.map_type.geofield_widget.yml b/modules/core/map/config/install/farm_map.map_type.geofield_widget.yml deleted file mode 100644 index e8fd56035..000000000 --- a/modules/core/map/config/install/farm_map.map_type.geofield_widget.yml +++ /dev/null @@ -1,11 +0,0 @@ -langcode: en -status: true -dependencies: - enforced: - module: - - farm_map -id: geofield_widget -label: Geofield widget -description: 'Renders a geofield widget using farmOS-map.' -behaviors: { } -options: { } diff --git a/modules/core/map/farm_map.libraries.yml b/modules/core/map/farm_map.libraries.yml index f286062c4..50db0321e 100644 --- a/modules/core/map/farm_map.libraries.yml +++ b/modules/core/map/farm_map.libraries.yml @@ -30,9 +30,9 @@ behavior_wkt: dependencies: - core/drupalSettings - farm_map/farm_map -behavior_geofield: +behavior_input: js: - js/farmOS.map.behaviors.geofield.js: { } + js/farmOS.map.behaviors.input.js: { } dependencies: - farm_map/farm_map behavior_enable_side_panel: diff --git a/modules/core/map/farm_map.post_update.php b/modules/core/map/farm_map.post_update.php new file mode 100644 index 000000000..c64245074 --- /dev/null +++ b/modules/core/map/farm_map.post_update.php @@ -0,0 +1,40 @@ + 'input', + 'label' => 'Input', + 'description' => 'Syncs editable map layer data into a form input.', + 'library' => 'farm_map/behavior_input', + 'settings' => [], + 'dependencies' => [ + 'enforced' => [ + 'module' => [ + 'farm_map', + ], + ], + ], + ]); + $input_behavior->save(); + + // Delete the geofield behavior. + $geofield_behavior = MapBehavior::load('geofield'); + $geofield_behavior->delete(); + + // Delete the geofield_widget map type. + $geofield_widget_map_type = MapType::load('geofield_widget'); + $geofield_widget_map_type->delete(); +} diff --git a/modules/core/map/js/farmOS.map.behaviors.geofield.js b/modules/core/map/js/farmOS.map.behaviors.input.js similarity index 91% rename from modules/core/map/js/farmOS.map.behaviors.geofield.js rename to modules/core/map/js/farmOS.map.behaviors.input.js index 6b4b1665a..127de36e1 100644 --- a/modules/core/map/js/farmOS.map.behaviors.geofield.js +++ b/modules/core/map/js/farmOS.map.behaviors.input.js @@ -1,5 +1,5 @@ (function () { - farmOS.map.behaviors.geofield = { + farmOS.map.behaviors.input = { attach: function (instance) { instance.editAttached.then(() => { instance.edit.wktOn('featurechange', function(wkt) { diff --git a/modules/core/map/src/Element/FarmMap.php b/modules/core/map/src/Element/FarmMap.php index 7f2b795fa..8f9ae48e7 100644 --- a/modules/core/map/src/Element/FarmMap.php +++ b/modules/core/map/src/Element/FarmMap.php @@ -24,6 +24,8 @@ class FarmMap extends RenderElement { ], '#theme' => 'farm_map', '#map_type' => 'default', + '#map_settings' => [], + '#behaviors' => [], ]; } @@ -37,6 +39,8 @@ class FarmMap extends RenderElement { * * @return array * A renderable array representing the map. + * + * @see \Drupal\farm_map\Event\MapRenderEvent */ public static function preRenderMap(array $element) { @@ -66,14 +70,20 @@ class FarmMap extends RenderElement { $element['#attached']['library'][] = 'farm_map/farmOS-map'; $element['#attached']['library'][] = 'farm_map/farm_map'; - // Include map settings. - $map_settings = !empty($element['#map_settings']) ? $element['#map_settings'] : []; + // If #behaviors are included, attach each one. + foreach ($element['#behaviors'] as $behavior_name) { + /** @var \Drupal\farm_map\Entity\MapBehaviorInterface $behavior */ + $behavior = \Drupal::entityTypeManager()->getStorage('map_behavior')->load($behavior_name); + if (!empty($behavior)) { + $element['#attached']['library'][] = $behavior->getLibrary(); + } + } // Include the map options. $map_options = $map->getMapOptions(); // Add the instance settings under the map id key. - $instance_settings = array_merge_recursive($map_settings, $map_options); + $instance_settings = array_merge_recursive($element['#map_settings'], $map_options); $element['#attached']['drupalSettings']['farm_map'][$map_id] = $instance_settings; // Create and dispatch a MapRenderEvent. diff --git a/modules/core/map/src/Element/FarmMapInput.php b/modules/core/map/src/Element/FarmMapInput.php new file mode 100644 index 000000000..a8faf5d61 --- /dev/null +++ b/modules/core/map/src/Element/FarmMapInput.php @@ -0,0 +1,149 @@ + TRUE, + '#process' => [ + [$class, 'processElement'], + ], + '#pre_render' => [ + [$class, 'preRenderGroup'], + ], + '#element_validate' => [ + [$class, 'elementValidate'], + ], + '#theme_wrappers' => ['form_element'], + // Display descriptions above the map by default. + '#description_display' => 'before', + '#map_type' => 'default', + '#map_settings' => [], + '#behaviors' => [], + '#default_value' => '', + '#display_raw_geometry' => FALSE, + '#disabled' => FALSE, + ]; + } + + /** + * Generates the form element. + * + * @param array $element + * An associative array containing the properties and children of the + * element. Note that $element must be taken by reference here, so processed + * child elements are taken over into $form_state. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param array $complete_form + * The complete form structure. + * + * @return array + * The processed element. + */ + public static function processElement(array $element, FormStateInterface $form_state, array &$complete_form) { + $element['#tree'] = TRUE; + + // Merge provided map behaviors into defaults. Enable wkt and input + // behaviors if #disabled is not TRUE. + $default_behaviors = !$element['#disabled'] ? ['wkt', 'input'] : []; + $behaviors = array_merge($default_behaviors, $element['#behaviors']); + + // Recursively merge provided map settings into defaults. + $map_settings = array_merge_recursive([ + 'behaviors' => [ + 'wkt' => [ + 'edit' => !$element['#disabled'], + 'zoom' => TRUE, + ], + ], + ], $element['#map_settings']); + + // Define the map render array. + $element['map'] = [ + '#type' => 'farm_map', + '#map_type' => $element['#map_type'], + '#map_settings' => $map_settings, + '#behaviors' => $behaviors, + ]; + + // Add a textarea for the WKT value. + $display_raw_geometry = $element['#display_raw_geometry']; + $element_title = $element['#title'] ?? t('Geometry'); + $element['value'] = [ + '#type' => $display_raw_geometry ? 'textarea' : 'hidden', + '#title' => $element_title . ' ' . t('WKT'), + '#title_display' => 'invisible', + '#attributes' => [ + 'data-map-geometry-field' => TRUE, + ], + '#disabled' => $element['#disabled'], + ]; + + // Add default value if provided. + if (!empty($element['#default_value'])) { + $element['map']['#map_settings']['wkt'] = $element['#default_value']; + $element['value']['#default_value'] = $element['#default_value']; + } + + // Return the element. + return $element; + } + + /** + * Validates the form element. + */ + public static function elementValidate(&$element, FormStateInterface $form_state, &$complete_form) { + + // Validate that the geometry data is valid by attempting to load it into + // GeoPHP. This uses the same logic and error message as the geofield + // module's validation constraint. + // @see Drupal\geofield\Plugin\Validation\Constraint\GeoConstraint + // @see Drupal\geofield\Plugin\Validation\Constraint\GeoConstraintValidator + $value = $element['value']['#value']; + if (!empty($value)) { + $geophp = new GeoPHPWrapper(); + $valid_geometry = TRUE; + try { + if (!$geophp->load($value)) { + $valid_geometry = FALSE; + } + } + catch (\Exception $e) { + $valid_geometry = FALSE; + } + if (!$valid_geometry) { + $form_state->setError($element, t('"@value" is not a valid geospatial content.', ['@value' => $value])); + } + } + + // Save the WKT string value to the overall element. + $form_state->setValueForElement($element, $value); + } + + /** + * {@inheritdoc} + */ + public static function valueCallback(&$element, $input, FormStateInterface $form_state) { + if ($input === FALSE) { + return $element['#default_value'] ?: ''; + } + return $input; + } + +} diff --git a/modules/core/map/src/Event/MapRenderEvent.php b/modules/core/map/src/Event/MapRenderEvent.php index 3073cb2b8..5829ad075 100644 --- a/modules/core/map/src/Event/MapRenderEvent.php +++ b/modules/core/map/src/Event/MapRenderEvent.php @@ -72,6 +72,8 @@ class MapRenderEvent extends Event { * * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * + * @see \Drupal\farm_map\Element\FarmMap */ public function addBehavior(string $behavior_name, array $settings = []) { diff --git a/modules/core/map/src/EventSubscriber/MapRenderEventSubscriber.php b/modules/core/map/src/EventSubscriber/MapRenderEventSubscriber.php index 71ef0de42..783aed7c5 100644 --- a/modules/core/map/src/EventSubscriber/MapRenderEventSubscriber.php +++ b/modules/core/map/src/EventSubscriber/MapRenderEventSubscriber.php @@ -61,12 +61,6 @@ class MapRenderEventSubscriber implements EventSubscriberInterface { $event->addBehavior('wkt'); } - // Add the wkt and geofield behavior to the geofield_widget map. - if (in_array($event->getMapType()->id(), ['geofield_widget'])) { - $event->addBehavior('wkt'); - $event->addBehavior('geofield'); - } - // Get whether the side panel should be enabled. $enable_side_panel = $this->configFactory->get('farm_map.settings')->get('enable_side_panel'); diff --git a/modules/core/map/src/Plugin/Field/FieldWidget/GeofieldWidget.php b/modules/core/map/src/Plugin/Field/FieldWidget/GeofieldWidget.php index 13849d72a..dfab211c8 100644 --- a/modules/core/map/src/Plugin/Field/FieldWidget/GeofieldWidget.php +++ b/modules/core/map/src/Plugin/Field/FieldWidget/GeofieldWidget.php @@ -133,45 +133,30 @@ class GeofieldWidget extends GeofieldBaseWidget { */ public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { - // Wrap the map in a collapsible details element. + // Use the farm_map_input form element. + $element['#type'] = 'farm_map_input'; + + // Use the geofield map type. + $element['#map_type'] = 'geofield'; + + // Wrap in a fieldset. + $element['#theme_wrappers'] = ['fieldset']; + + // Wrap the map with a unique id for populating from files. $field_name = $this->fieldDefinition->getName(); $field_wrapper_id = Html::getUniqueId($field_name . '_wrapper'); - $element['#type'] = 'details'; - $element['#title'] = $this->t('Geometry'); - $element['#open'] = TRUE; $element['#prefix'] = '
'; $element['#suffix'] = '
'; // Get the current form state value. Prioritize form state over field value. - $form_value = $form_state->getValue([$field_name, $delta, 'value']); + $form_value = $form_state->getValue([$field_name, $delta]); $field_value = $items[$delta]->value; - $current_value = $form_value ?? $field_value; + $current_value = $form_value['value'] ?? $field_value; + $element['#default_value'] = $current_value; - // Define the map render array. - $element['map'] = [ - '#type' => 'farm_map', - '#map_type' => 'geofield_widget', - '#map_settings' => [ - 'wkt' => $current_value, - 'behaviors' => [ - 'wkt' => [ - 'edit' => TRUE, - 'zoom' => TRUE, - ], - ], - ], - ]; - - // Add a textarea for the WKT value. + // Configure to display raw geometry. $display_raw_geometry = $this->getSetting('display_raw_geometry'); - $element['value'] = [ - '#type' => $display_raw_geometry ? 'textarea' : 'hidden', - '#title' => $this->t('Geometry'), - '#default_value' => $current_value, - '#attributes' => [ - 'data-map-geometry-field' => TRUE, - ], - ]; + $element['#display_raw_geometry'] = $display_raw_geometry; // Add an option to populate geometry using files field. // The "populate_file_field" field setting must be configured and the @@ -192,9 +177,16 @@ class GeofieldWidget extends GeofieldBaseWidget { ':input[name="' . $populate_file_field . '[0][fids]"]' => ['empty' => TRUE], ], ], + '#weight' => 10, ]; } + // Override the element validation to prevent transformation of the value + // from array to string, and because Geofields already perform the same + // geometry validation. + // @see \Drupal\geofield\Plugin\Validation\GeoConstraintValidator. + $element['#element_validate'] = []; + return $element; } @@ -281,11 +273,11 @@ class GeofieldWidget extends GeofieldBaseWidget { $field_name = $this->fieldDefinition->getName(); $delta = $element['#delta']; $user_input = $form_state->getUserInput(); - unset($user_input[$field_name][$delta]['value']); + unset($user_input[$field_name][$delta]); $form_state->setUserInput($user_input); // Set the new form value. - $form_state->setValue([$field_name, $delta, 'value'], $wkt); + $form_state->setValue([$field_name, $delta], ['value' => $wkt]); // Rebuild the form so the map widget is rebuilt with the new value. $form_state->setRebuild(TRUE); diff --git a/modules/core/map/tests/modules/farm_map_test/config/install/log.type.test.yml b/modules/core/map/tests/modules/farm_map_test/config/install/log.type.test.yml new file mode 100644 index 000000000..d88475de0 --- /dev/null +++ b/modules/core/map/tests/modules/farm_map_test/config/install/log.type.test.yml @@ -0,0 +1,8 @@ +langcode: en +status: true +id: test +label: Test +description: '' +name_pattern: 'Test log [log:id]' +workflow: log_default +new_revision: true diff --git a/modules/core/map/tests/modules/farm_map_test/farm_map_test.info.yml b/modules/core/map/tests/modules/farm_map_test/farm_map_test.info.yml new file mode 100644 index 000000000..f48fb9a88 --- /dev/null +++ b/modules/core/map/tests/modules/farm_map_test/farm_map_test.info.yml @@ -0,0 +1,10 @@ +name: farmOS map tests +type: module +description: Support module for farmOS map testing. +package: Testing +core_version_requirement: ^8.8 || ^9 +dependencies: + - farm:farm_location + - farm:farm_map + - farm:farm_quick + - log:log diff --git a/modules/core/map/tests/modules/farm_map_test/src/Plugin/QuickForm/Test.php b/modules/core/map/tests/modules/farm_map_test/src/Plugin/QuickForm/Test.php new file mode 100644 index 000000000..2138f812b --- /dev/null +++ b/modules/core/map/tests/modules/farm_map_test/src/Plugin/QuickForm/Test.php @@ -0,0 +1,67 @@ + 'farm_map_input', + '#title' => $this->t('Geometry 1'), + '#display_raw_geometry' => FALSE, + '#default_value' => 'POINT(-42.689862437640826 32.621823310499934)', + ]; + + // Geometry field without a default value and a raw data field. + $form['geometry2'] = [ + '#type' => 'farm_map_input', + '#title' => $this->t('Geometry 2'), + '#display_raw_geometry' => TRUE, + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + + // Create two logs. + $this->createLog([ + 'type' => 'test', + 'name' => 'Test 1', + 'geometry' => $form_state->getValue('geometry1'), + ]); + $this->createLog([ + 'type' => 'test', + 'name' => 'Test 2', + 'geometry' => $form_state->getValue('geometry2'), + ]); + } + +} diff --git a/modules/core/map/tests/src/Functional/MapFormTest.php b/modules/core/map/tests/src/Functional/MapFormTest.php new file mode 100644 index 000000000..34693b5d5 --- /dev/null +++ b/modules/core/map/tests/src/Functional/MapFormTest.php @@ -0,0 +1,69 @@ +createUser(['create test log']); + $this->drupalLogin($user); + + // Go to the test quick form and confirm that both of the geometry fields + // are visible, and only the second field's WKT text field is visible. + $this->drupalGet('quick/test'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains($this->t('Geometry 1')); + $this->assertSession()->pageTextContains($this->t('Geometry 2')); + $this->assertSession()->pageTextNotContains($this->t('Geometry 1 WKT')); + $this->assertSession()->pageTextContains($this->t('Geometry 2 WKT')); + + // Submit the form with a value for the second geometry. + $edit = ['geometry2[value]' => 'POINT(-45.967095060886315 32.77503850904169)']; + $this->submitForm($edit, 'Submit'); + + // Load logs. + $logs = \Drupal::entityTypeManager()->getStorage('log')->loadMultiple(); + + // Confirm that two logs were created. + $this->assertCount(2, $logs); + + // Check that the first log's geometry was populated with the form field's + // default value. + $log = $logs[1]; + $this->assertEquals('POINT(-42.689862437640826 32.621823310499934)', $log->get('geometry')->value); + + // Check that the second log's geometry field was populated with the value + // entered into the form. + $log = $logs[2]; + $this->assertEquals('POINT(-45.967095060886315 32.77503850904169)', $log->get('geometry')->value); + + // Test that submitting an invalid geometry throws a form validation error. + $this->drupalGet('quick/test'); + $edit = ['geometry2[value]' => 'POLYGON()']; + $this->submitForm($edit, 'Submit'); + $this->assertSession()->pageTextContains($this->t('"POLYGON()" is not a valid geospatial content.')); + } + +} diff --git a/modules/core/quick/src/Form/QuickForm.php b/modules/core/quick/src/Form/QuickForm.php index bb9a98198..7837e3a95 100644 --- a/modules/core/quick/src/Form/QuickForm.php +++ b/modules/core/quick/src/Form/QuickForm.php @@ -2,6 +2,7 @@ namespace Drupal\farm_quick\Form; +use Drupal\Core\Form\BaseFormIdInterface; use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Session\AccountInterface; @@ -13,7 +14,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface; * * @ingroup farm */ -class QuickForm extends FormBase { +class QuickForm extends FormBase implements BaseFormIdInterface { /** * The quick form manager. @@ -51,10 +52,18 @@ class QuickForm extends FormBase { /** * {@inheritdoc} */ - public function getFormId() { + public function getBaseFormId() { return 'quick_form'; } + /** + * {@inheritdoc} + */ + public function getFormId() { + $id = $this->getRouteMatch()->getParameter('id'); + return $this->getBaseFormId() . "_$id"; + } + /** * Get the title of the quick form. * diff --git a/modules/core/ui/map/src/EventSubscriber/MapRenderEventSubscriber.php b/modules/core/ui/map/src/EventSubscriber/MapRenderEventSubscriber.php index 1683bdeff..1d75f4720 100644 --- a/modules/core/ui/map/src/EventSubscriber/MapRenderEventSubscriber.php +++ b/modules/core/ui/map/src/EventSubscriber/MapRenderEventSubscriber.php @@ -65,7 +65,7 @@ class MapRenderEventSubscriber implements EventSubscriberInterface { $map_id = $event->getmapType()->id(); // Add behaviors/settings to default and geofield maps. - if (in_array($map_id, ['default', 'geofield', 'geofield_widget'])) { + if (in_array($map_id, ['default', 'geofield'])) { // Add "All locations" layers. $event->addBehavior('asset_type_layers');