Movement quick form (ported from farmOS 1.x) #677

This commit is contained in:
Michael Stenta 2023-05-05 08:37:23 -04:00
parent 47ac9ae5dd
commit 8c0de9c349
8 changed files with 571 additions and 0 deletions

View File

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [Add QuickStringTrait::entityLabelsSummary() method for summarizing entity labels #675](https://github.com/farmOS/farmOS/pull/675)
- [Add asset inventory views field #679](https://github.com/farmOS/farmOS/pull/679)
- [Birth quick form (ported from farmOS 1.x) #656](https://github.com/farmOS/farmOS/pull/656)
- [Movement quick form (ported from farmOS 1.x) #677](https://github.com/farmOS/farmOS/pull/677)
### Changed

View File

@ -0,0 +1,11 @@
langcode: en
status: true
dependencies:
enforced:
module:
- farm_quick_movement
id: quick_movement
label: Quick movement
description: 'Refreshes map from movement quick form inputs.'
library: 'farm_quick_movement/behavior_quick_movement'
settings: { }

View File

@ -0,0 +1,8 @@
name: Movement Quick Form
description: Provides a quick form for recording asset movements.
type: module
package: farmOS Quick Forms
core_version_requirement: ^9
dependencies:
- farm:farm_activity
- farm:farm_quick

View File

@ -0,0 +1,8 @@
quick_movement:
js:
js/quick_movement.js: { }
behavior_quick_movement:
js:
js/farmOS.map.behaviors.quick_movement.js: { }
dependencies:
- farm_map/farm_map

View File

@ -0,0 +1,29 @@
(function () {
farmOS.map.behaviors.quick_movement = {
attach: function (instance) {
// Create a layer for the current asset location.
var opts = {
title: 'Current Location',
color: 'blue',
};
instance.currentLocationLayer = instance.addLayer('vector', opts);
},
// When updating asset geometry, update the current location layer.
updateAssetGeometry: function (instance, wkt) {
// Clear features from the layer.
instance.currentLocationLayer.getSource().clear();
// If WKT is not empty, add features to the layer and zoom.
if (wkt) {
instance.currentLocationLayer.getSource().addFeatures(instance.readFeatures('wkt', wkt));
instance.zoomToLayer(instance.currentLocationLayer);
}
},
// Make sure this runs after farmOS.map.behaviors.wkt.
weight: 101,
};
}());

View File

@ -0,0 +1,32 @@
(function (Drupal) {
Drupal.behaviors.quick_movement = {
attach: function (context, settings) {
// Only run this when the asset geometry or location geometry wrappers
// are loaded/reloaded.
if (!context.dataset || !(context.dataset.movementGeometry === 'asset-geometry' || context.dataset.movementGeometry === 'location-geometry')) {
return;
}
// Get WKT from the hidden input field.
var wkt = context.querySelector('input').value;
// Get the farmOS-map element and instance.
var element = context.parentElement.querySelector('[data-drupal-selector="edit-geometry-map"]');
var instance = farmOS.map.instances[farmOS.map.targetIndex(element)];
// If this is asset geometry, refresh the map asset geometry.
if (context.dataset.movementGeometry === 'asset-geometry') {
farmOS.map.behaviors.quick_movement.updateAssetGeometry(instance, wkt);
}
// If this is location geometry, copy WKT into the map's value field and
// dispatch the input event so that the input behavior refreshes the map.
if (context.dataset.movementGeometry === 'location-geometry') {
var input = context.parentElement.querySelector('[data-drupal-selector="edit-geometry-value"]');
input.value = wkt;
input.dispatchEvent(new Event('input'));
}
}
};
}(Drupal));

View File

@ -0,0 +1,326 @@
<?php
namespace Drupal\farm_quick_movement\Plugin\QuickForm;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\farm_geo\Traits\WktTrait;
use Drupal\farm_location\AssetLocationInterface;
use Drupal\farm_quick\Plugin\QuickForm\QuickFormBase;
use Drupal\farm_quick\Plugin\QuickForm\QuickFormInterface;
use Drupal\farm_quick\Traits\QuickFormElementsTrait;
use Drupal\farm_quick\Traits\QuickLogTrait;
use Drupal\farm_quick\Traits\QuickStringTrait;
use Psr\Container\ContainerInterface;
/**
* Movement quick form.
*
* @QuickForm(
* id = "movement",
* label = @Translation("Movement"),
* description = @Translation("Record the movement of assets."),
* helpText = @Translation("Use this form to record the movement of assets to a new location."),
* permissions = {
* "create activity log",
* }
* )
*/
class Movement extends QuickFormBase implements QuickFormInterface {
use QuickLogTrait;
use QuickFormElementsTrait;
use QuickStringTrait;
use WktTrait;
/**
* The entity type manager service.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Asset location service.
*
* @var \Drupal\farm_location\AssetLocationInterface
*/
protected $assetLocation;
/**
* Current user object.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* Constructs a QuickFormBase object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger service.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager service.
* @param \Drupal\farm_location\AssetLocationInterface $asset_location
* Asset location service.
* @param \Drupal\Core\Session\AccountInterface $current_user
* Current user object.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, MessengerInterface $messenger, EntityTypeManagerInterface $entity_type_manager, AssetLocationInterface $asset_location, AccountInterface $current_user) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $messenger);
$this->messenger = $messenger;
$this->entityTypeManager = $entity_type_manager;
$this->assetLocation = $asset_location;
$this->currentUser = $current_user;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('messenger'),
$container->get('entity_type.manager'),
$container->get('asset.location'),
$container->get('current_user'),
);
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, string $id = NULL) {
// Date.
$form['date'] = [
'#type' => 'datetime',
'#title' => $this->t('Date'),
'#default_value' => new DrupalDateTime('midnight', $this->currentUser->getTimeZone()),
'#required' => TRUE,
];
// Assets.
$form['asset'] = [
'#type' => 'entity_autocomplete',
'#title' => $this->t('Assets'),
'#description' => $this->t('Which assets are moving?'),
'#target_type' => 'asset',
'#selection_settings' => [
'sort' => [
'field' => 'status',
'direction' => 'ASC',
],
],
'#maxlength' => 1024,
'#tags' => TRUE,
'#required' => TRUE,
'#ajax' => [
'callback' => [$this, 'assetGeometryCallback'],
'wrapper' => 'asset-geometry',
'event' => 'autocompleteclose change',
],
];
// Locations.
$form['location'] = [
'#type' => 'entity_autocomplete',
'#title' => $this->t('Locations'),
'#description' => $this->t('Where are the assets moving to?'),
'#target_type' => 'asset',
'#selection_handler' => 'views',
'#selection_settings' => [
'view' => [
'view_name' => 'farm_location_reference',
'display_name' => 'entity_reference',
'arguments' => [],
],
'match_operator' => 'CONTAINS',
],
'#maxlength' => 1024,
'#tags' => TRUE,
'#ajax' => [
'callback' => [$this, 'locationGeometryCallback'],
'wrapper' => 'location-geometry',
'event' => 'autocompleteclose change',
],
];
// Geometry.
$form['geometry'] = [
'#type' => 'farm_map_input',
'#title' => $this->t('Geometry'),
'#description' => $this->t('The current geometry of the assets is blue. The new geometry is orange. It is copied from the locations selected above, and can be modified to give the assets a more specific geometry.'),
'#behaviors' => [
'quick_movement',
],
'#display_raw_geometry' => TRUE,
];
// Hidden fields to store asset and location geometry.
$form['asset_geometry_wrapper'] = [
'#type' => 'container',
'#attributes' => [
'id' => 'asset-geometry',
'data-movement-geometry' => 'asset-geometry',
],
'asset_geometry' => [
'#type' => 'hidden',
'#value' => $this->combinedAssetGeometries($this->loadEntityAutocompleteAssets($form_state->getValue('asset'))),
],
];
$form['location_geometry_wrapper'] = [
'#type' => 'container',
'#attributes' => [
'id' => 'location-geometry',
'data-movement-geometry' => 'location-geometry',
],
'location_geometry' => [
'#type' => 'hidden',
'#value' => $this->combinedAssetGeometries($this->loadEntityAutocompleteAssets($form_state->getValue('location'))),
],
];
// Notes.
$form['notes'] = [
'#type' => 'details',
'#title' => $this->t('Notes'),
];
$form['notes']['notes'] = [
'#type' => 'text_format',
'#title' => $this->t('Notes'),
'#title_display' => 'invisible',
'#format' => 'default',
];
// Done.
$form['done'] = [
'#type' => 'checkbox',
'#title' => $this->t('Completed'),
'#default_value' => TRUE,
];
// Attach movement quick form JS.
$form['#attached']['library'][] = 'farm_quick_movement/quick_movement';
return $form;
}
/**
* Ajax callback for asset geometry field.
*/
public function assetGeometryCallback(array $form, FormStateInterface $form_state) {
return $form['asset_geometry_wrapper'];
}
/**
* Ajax callback for location geometry field.
*/
public function locationGeometryCallback(array $form, FormStateInterface $form_state) {
return $form['location_geometry_wrapper'];
}
/**
* Load assets from entity_autocomplete values.
*
* @param array|null $values
* The value from $form_state->getValue().
*
* @return \Drupal\asset\Entity\AssetInterface[]
* Returns an array of assets.
*/
protected function loadEntityAutocompleteAssets($values) {
$entities = [];
if (empty($values)) {
return $entities;
}
foreach ($values as $value) {
if ($value instanceof EntityInterface) {
$entities[] = $value;
}
elseif (!empty($value['target_id'])) {
$entities[] = $this->entityTypeManager->getStorage('asset')->load($value['target_id']);
}
}
return $entities;
}
/**
* Load combined WKT geometry of assets.
*
* @param array $assets
* An array of assets.
*
* @return string
* Returns a WKT geometry string.
*/
protected function combinedAssetGeometries(array $assets) {
if (empty($assets)) {
return '';
}
$geometries = [];
foreach ($assets as $asset) {
$geometries[] = $this->assetLocation->getGeometry($asset);
}
return $this->combineWkt($geometries);
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
// Validate that a geometry is only present if a location is specified.
if (empty($form_state->getValue('location')) && !empty($form_state->getValue('geometry'))) {
$form_state->setError($form['geometry'], $this->t('A geometry cannot be set if there is no location.'));
}
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
// Draft a movement activity log from the user-submitted data.
$timestamp = $form_state->getValue('date')->getTimestamp();
$status = $form_state->getValue('done') ? 'done' : 'pending';
$log = [
'type' => 'activity',
'timestamp' => $timestamp,
'asset' => $form_state->getValue('asset'),
'location' => $form_state->getValue('location'),
'geometry' => $form_state->getValue('geometry'),
'notes' => $form_state->getValue('notes'),
'status' => $status,
'is_movement' => TRUE,
];
// Load assets and locations.
$assets = $this->loadEntityAutocompleteAssets($form_state->getValue('asset'));
$locations = $this->loadEntityAutocompleteAssets($form_state->getValue('location'));
// Generate a name for the log.
$asset_names = $this->entityLabelsSummary($assets);
$location_names = $this->entityLabelsSummary($locations);
$log['name'] = $this->t('Clear location of @assets', ['@assets' => $asset_names]);
if (!empty($location_names)) {
$log['name'] = $this->t('Move @assets to @locations', ['@assets' => $asset_names, '@locations' => $location_names]);
}
// Create the log.
$this->createLog($log);
}
}

View File

@ -0,0 +1,156 @@
<?php
namespace Drupal\Tests\farm_quick_movement\Kernel;
use Drupal\asset\Entity\Asset;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Tests\farm_quick\Kernel\QuickFormTestBase;
/**
* Tests for farmOS movement quick form.
*
* @group farm
*/
class QuickMovementTest extends QuickFormTestBase {
/**
* Quick form ID.
*
* @var string
*/
protected $quickFormId = 'movement';
/**
* {@inheritdoc}
*/
protected static $modules = [
'farm_equipment',
'farm_activity',
'farm_land',
'farm_quick_movement',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installConfig([
'farm_activity',
'farm_equipment',
'farm_land',
]);
}
/**
* Test movement quick form submission.
*/
public function testQuickMovement() {
// Get today's date.
$today = new DrupalDateTime('midnight');
// Create two equipment assets and two land assets.
$equipment1 = Asset::create([
'name' => 'Tractor',
'type' => 'equipment',
'status' => 'active',
]);
$equipment1->save();
$equipment2 = Asset::create([
'name' => 'Combine',
'type' => 'equipment',
'status' => 'active',
]);
$equipment2->save();
$location1 = Asset::create([
'name' => 'Field A',
'type' => 'land',
'land_type' => 'field',
'is_fixed' => TRUE,
'is_location' => TRUE,
'intrinsic_geometry' => 'POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))',
'status' => 'active',
]);
$location1->save();
$location2 = Asset::create([
'name' => 'Field B',
'type' => 'land',
'land_type' => 'field',
'is_fixed' => TRUE,
'is_location' => TRUE,
'intrinsic_geometry' => 'POLYGON ((20 40, 40 80, 60 60, 10 20, 20 40))',
'status' => 'active',
]);
$location2->save();
// Programmatically submit the movement quick form.
$form_values = [
'date' => [
'date' => $today->format('Y-m-d'),
'time' => $today->format('H:i:s'),
],
'asset' => [
['target_id' => $equipment1->id()],
['target_id' => $equipment2->id()],
],
'location' => [
['target_id' => $location1->id()],
['target_id' => $location2->id()],
],
'notes' => [
'value' => 'Lorem ipsum',
'format' => 'default',
],
'done' => TRUE,
];
$this->submitQuickForm($form_values);
// Load logs.
$logs = $this->logStorage->loadMultiple();
// Confirm that one log exists.
$this->assertCount(1, $logs);
// Check that the activity log's fields were populated correctly.
$log = $logs[1];
$this->assertEquals('activity', $log->bundle());
$this->assertEquals($today->getTimestamp(), $log->get('timestamp')->value);
$this->assertEquals('Move Tractor, Combine to Field A, Field B', $log->label());
$this->assertEquals($equipment1->id(), $log->get('asset')->referencedEntities()[0]->id());
$this->assertEquals($equipment2->id(), $log->get('asset')->referencedEntities()[1]->id());
$this->assertEquals($location1->id(), $log->get('location')->referencedEntities()[0]->id());
$this->assertEquals($location2->id(), $log->get('location')->referencedEntities()[1]->id());
$this->assertEquals('Lorem ipsum', $log->get('notes')->value);
$this->assertEquals('GEOMETRYCOLLECTION (POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10)),POLYGON ((20 40, 40 80, 60 60, 10 20, 20 40)))', $log->get('geometry')->value);
$this->assertEquals('done', $log->get('status')->value);
// Programmatically submit the movement quick form again, but this time
// override the geometry.
$form_values['geometry']['value'] = 'POINT (30 10)';
$this->submitQuickForm($form_values);
// Load logs.
$logs = $this->logStorage->loadMultiple();
// Confirm that two logs exist.
$this->assertCount(2, $logs);
// Check that the geometry was overridden.
$log = $logs[2];
$this->assertEquals($form_values['geometry']['value'], $log->get('geometry')->value);
// Programmatically submit the movement quick form again, but this time
// remove the location without removing geometry. This should fail
// validation.
$form_values['location'] = NULL;
$this->submitQuickForm($form_values);
// Load logs.
$logs = $this->logStorage->loadMultiple();
// Confirm that only two logs still exist.
$this->assertCount(2, $logs);
}
}