diff --git a/CHANGELOG.md b/CHANGELOG.md index 927bb26..5ec81a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Bulk Edit Changelog +## 1.1.1 - 2019-07-22 +### Fixed +- Fixed potential issues with merge strategies defaulting to replace + +### Changed +- Abstracted field handling + +### Added +- `EVENT_REGISTER_FIELD_PROCESSORS` + +### Removed +- `EVENT_REGISTER_SUPPORTED_FIELDS` use `EVENT_REGISTER_FIELD_PROCESSORS` instead + ## 1.1.0.1 - 2019-07-21 ### Fixed - Fixed an issue with bulkEdit component not being set diff --git a/composer.json b/composer.json index 670e63d..715f749 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "venveo/craft-bulkedit", "description": "Bulk edit entries", "type": "craft-plugin", - "version": "1.1.0.1", + "version": "1.1.1", "keywords": [ "craft", "cms", diff --git a/src/base/AbstractFieldProcessor.php b/src/base/AbstractFieldProcessor.php new file mode 100644 index 0000000..1db6ac9 --- /dev/null +++ b/src/base/AbstractFieldProcessor.php @@ -0,0 +1,56 @@ +handle; + $element->setFieldValue($fieldHandle, $value); + } + + public static function performSubtraction(Element $element, Field $field, $value): void + { + throw new \RuntimeException('Subtraction not implemented for this field type'); + } + + /** + * @param FieldInterface $field + * @return bool + */ + public static function supportsField(FieldInterface $field): bool + { + foreach (static::getSupportedFields() as $fieldType) { + if ($field instanceof $fieldType) { + return true; + } + } + return false; + } + + public static function processElementField(Element $element, Field $field, $strategy, $newValue): void + { + switch ($strategy) { + case BulkEdit::STRATEGY_REPLACE: + static::performReplacement($element, $field, $newValue); + break; + case BulkEdit::STRATEGY_MERGE: + static::performMerge($element, $field, $newValue); + break; + case BulkEdit::STRATEGY_SUBTRACT: + static::performSubtraction($element, $field, $newValue); + break; + } + } +} \ No newline at end of file diff --git a/src/base/FieldProcessorInterface.php b/src/base/FieldProcessorInterface.php new file mode 100644 index 0000000..6fc6d2d --- /dev/null +++ b/src/base/FieldProcessorInterface.php @@ -0,0 +1,27 @@ +bulkEdit->saveContext($elementType, $siteId, $elementIds, $fieldIds, $keyedFieldValues); + Plugin::$plugin->bulkEdit->saveContext($elementType, $siteId, $elementIds, $fieldIds, $keyedFieldValues, $fieldStrategies); return $this->asJson([ 'success' => true diff --git a/src/fields/processors/PlainTextProcessor.php b/src/fields/processors/PlainTextProcessor.php new file mode 100644 index 0000000..fd461aa --- /dev/null +++ b/src/fields/processors/PlainTextProcessor.php @@ -0,0 +1,62 @@ +plugins->isPluginInstalled('redactor')) { + $fields[] = RedactorField::class; + } + + return $fields; + } + + /** + * Returns the supported strategies for this field type + * @return array + */ + public static function getSupportedStrategies(): array + { + return [BulkEdit::STRATEGY_REPLACE]; + } +} diff --git a/src/fields/processors/RelationFieldProcessor.php b/src/fields/processors/RelationFieldProcessor.php new file mode 100644 index 0000000..232b01e --- /dev/null +++ b/src/fields/processors/RelationFieldProcessor.php @@ -0,0 +1,54 @@ +handle; + $originalValue = $element->getFieldValue($fieldHandle); + $ids = $originalValue->ids(); + $ids = array_diff($ids, $value); + $element->setFieldValue($fieldHandle, $ids); + } + + public static function performMerge(Element $element, Field $field, $value): void + { + $originalValue = $element->getFieldValue($field->handle); + $fieldHandle = $field->handle; + $ids = $originalValue->ids(); + $ids = array_merge($ids, $value); + $element->setFieldValue($fieldHandle, $ids); + } +} diff --git a/src/queue/jobs/SaveBulkEditJob.php b/src/queue/jobs/SaveBulkEditJob.php index cd6b15e..1f9423b 100644 --- a/src/queue/jobs/SaveBulkEditJob.php +++ b/src/queue/jobs/SaveBulkEditJob.php @@ -61,7 +61,7 @@ public function execute($queue = null) throw new \Exception('Unexpected element type encountered!'); } - $history = Plugin::$plugin->bulkEdit->getPendingHistoryForElement($this->context, $element->id)->all(); + $history = Plugin::$plugin->bulkEdit->getPendingHistoryFromContext($this->context, $element->id)->all(); try { Craft::info('Starting processing bulk edit job', __METHOD__); Plugin::$plugin->bulkEdit->processHistoryItemsForElement($history, $element); diff --git a/src/services/BulkEdit.php b/src/services/BulkEdit.php index d694478..aef69c9 100644 --- a/src/services/BulkEdit.php +++ b/src/services/BulkEdit.php @@ -16,26 +16,16 @@ use craft\base\Field; use craft\base\FieldInterface; use craft\events\RegisterComponentTypesEvent; -use craft\fields\BaseRelationField; -use craft\fields\Checkboxes; -use craft\fields\Color; -use craft\fields\Date; -use craft\fields\Email; -use craft\fields\Lightswitch; -use craft\fields\MultiSelect; -use craft\fields\Number; -use craft\fields\PlainText; -use craft\fields\RadioButtons; -use craft\fields\Table; -use craft\fields\Url; use craft\records\FieldLayout; -use craft\redactor\Field as RedactorField; use venveo\bulkedit\base\AbstractElementTypeProcessor; +use venveo\bulkedit\base\AbstractFieldProcessor; use venveo\bulkedit\elements\processors\AssetProcessor; use venveo\bulkedit\elements\processors\CategoryProcessor; use venveo\bulkedit\elements\processors\EntryProcessor; use venveo\bulkedit\elements\processors\ProductProcessor; use venveo\bulkedit\elements\processors\UserProcessor; +use venveo\bulkedit\fields\processors\PlainTextProcessor; +use venveo\bulkedit\fields\processors\RelationFieldProcessor; use venveo\bulkedit\models\FieldWrapper; use venveo\bulkedit\queue\jobs\SaveBulkEditJob; use venveo\bulkedit\records\EditContext; @@ -53,7 +43,11 @@ class BulkEdit extends Component public const STRATEGY_SUBTRACT = 'subtract'; public const EVENT_REGISTER_ELEMENT_PROCESSORS = 'registerElementProcessors'; - public const EVENT_REGISTER_SUPPORTED_FIELDS = 'registerSupportedFields'; + public const EVENT_REGISTER_FIELD_PROCESSORS = 'registerFieldProcessors'; + + // Memoized values + private static $_ELEMENT_TYPE_PROCESSORS; + private static $_FIELD_TYPE_PROCESSORS; /** * Get all distinct field layouts from a set of elements @@ -63,7 +57,7 @@ class BulkEdit extends Component * @return FieldWrapper[] fields * @throws \ReflectionException */ - public function getFieldsForElementIds($elementIds, $elementType) + public function getFieldsForElementIds($elementIds, $elementType): array { // Works for entries $processor = $this->getElementTypeProcessor($elementType); @@ -91,18 +85,8 @@ public function getFieldsForElementIds($elementIds, $elementType) return $fields; } - - /** - * @param $id - * @return EditContext|null - */ - public function getBulkEditContextFromId($id): ?EditContext - { - return EditContext::findOne($id); - } - /** - * Gets all unique elements from incomplete bulk edit tasks + * Gets all unique elements from incomplete bulk edit jobs * * @param EditContext $context * @return \yii\db\ActiveQuery @@ -118,28 +102,18 @@ public function getPendingElementsHistoriesFromContext(EditContext $context): \y } /** - * Gets all pending bulk edit tasks + * Gets all pending bulk edit changes for a particular job * * @param EditContext $context * @return \yii\db\ActiveQueryInterface */ - public function getPendingHistoryFromContext(EditContext $context): \yii\db\ActiveQueryInterface + public function getPendingHistoryFromContext(EditContext $context, $elementId = null): \yii\db\ActiveQueryInterface { - return $context->getHistoryItems()->where(['=', 'status', 'pending']); - } - - /** - * Gets all pending tasks for a particular element - * - * @param EditContext $context - * @param $elementId - * @return \yii\db\ActiveQueryInterface - */ - public function getPendingHistoryForElement(EditContext $context, $elementId): \yii\db\ActiveQueryInterface - { - $items = $this->getPendingHistoryFromContext($context); - $items->where(['=', 'elementId', $elementId]); - return $items; + $query = $context->getHistoryItems()->where(['=', 'status', 'pending']); + if ($elementId !== null) { + $query->where(['=', 'elementId', $elementId]); + } + return $query; } /** @@ -161,34 +135,15 @@ public function processHistoryItemsForElement($historyItems, Element $element): $fieldHandle = $historyItem->field->handle; $newValue = \GuzzleHttp\json_decode($historyItem->newValue, true); $originalValue = $element->getFieldValue($historyItem->field->handle); + + // Store a snapshot of the original field value $historyItem->originalValue = \GuzzleHttp\json_encode($originalValue); $historyItem->status = 'completed'; + $field = \Craft::$app->fields->getFieldByHandle($fieldHandle); - switch ($historyItem->strategy) { - case self::STRATEGY_REPLACE: - $element->setFieldValue($fieldHandle, $newValue); - break; - case self::STRATEGY_MERGE: - if ($field && $field instanceof BaseRelationField) { - $ids = $originalValue->ids(); - $ids = array_merge($ids, $newValue); - $element->setFieldValue($fieldHandle, $ids); - - } else { - throw new \Exception("Can't merge field: " . $fieldHandle); - } - break; - case self::STRATEGY_SUBTRACT: - if ($field && $field instanceof BaseRelationField) { - $ids = $originalValue->ids(); - $ids = array_diff($ids, $newValue); - $element->setFieldValue($fieldHandle, $ids); - } else { - throw new \Exception("Can't subtract field: " . $fieldHandle); - } - break; - } + $processor = $this->getFieldProcessor($field, $historyItem->strategy); + $processor::processElementField($element, $field, $historyItem->strategy, $newValue); $historyItem->save(); Craft::info('Saved history item', __METHOD__); } @@ -205,31 +160,48 @@ public function processHistoryItemsForElement($historyItems, Element $element): } } - public function isFieldSupported(FieldInterface $field): bool + /** + * Gets a general list of all field types supported by the strategies we have + * @return array + */ + public function getSupportedFieldTypes() { - $supportedFields = [ - PlainText::class, - Number::class, - BaseRelationField::class, - Color::class, - Checkboxes::class, - Date::class, - Table::class, - RadioButtons::class, - Lightswitch::class, - Url::class, - Email::class, - MultiSelect::class - ]; + $fieldProcessors = $this->getFieldProcessors(); + $supportedFields = []; + /** @var AbstractFieldProcessor $fieldProcessor */ + foreach ($fieldProcessors as $fieldProcessor) { + $fields = $fieldProcessor::getSupportedFields(); + $supportedFields = array_merge($supportedFields, $fields); + } + return $supportedFields; + } - // Add support for redactor - if (\Craft::$app->getPlugins()->isPluginEnabled('redactor')) { - $supportedFields[] = RedactorField::class; + /** + * + * @param FieldInterface $field + * @return array + */ + public function getProcessorsKeyedByStrategyForField(FieldInterface $field) + { + $processors = $this->getFieldProcessors(); + + $processorsByStrategy = []; + + /** @var AbstractFieldProcessor $processor */ + foreach ($processors as $processor) { + if (!$processor::supportsField($field)) { + continue; + } + foreach ($processor::getSupportedStrategies() as $strategy) { + $processorsByStrategy[$strategy][] = $processor; + } } + return $processorsByStrategy; + } - $event = new RegisterComponentTypesEvent(); - $event->types = &$supportedFields; - $this->trigger(self::EVENT_REGISTER_SUPPORTED_FIELDS, $event); + public function isFieldSupported(FieldInterface $field, $strategy = null): bool + { + $supportedFields = $this->getSupportedFieldTypes(); foreach ($supportedFields as $fieldItem) { if ($field instanceof $fieldItem) { @@ -240,21 +212,30 @@ public function isFieldSupported(FieldInterface $field): bool } /** - * Gets an array of values for supported strategies on field types + * Gets an array of values for supported strategies on field types. This is used by the _fields template * @param FieldInterface $field * @return array */ - public function getSupportedStrategiesForField(FieldInterface $field) + public function getSupportedStrategiesForField(FieldInterface $field): array { - $availableStrategies = [ - ['value' => self::STRATEGY_REPLACE, 'label' => 'Replace'] - ]; - - if ($field instanceof BaseRelationField) { - $availableStrategies[] = ['value' => self::STRATEGY_MERGE, 'label' => 'Merge']; - $availableStrategies[] = ['value' => self::STRATEGY_SUBTRACT, 'label' => 'Subtract']; + $processorsList = $this->getProcessorsKeyedByStrategyForField($field); + + $availableStrategies = []; + foreach ($processorsList as $strategy => $processors) { + switch ($strategy) { + case self::STRATEGY_REPLACE: + $availableStrategies[] = ['value' => self::STRATEGY_REPLACE, 'label' => 'Replace']; + break; + case self::STRATEGY_MERGE: + $availableStrategies[] = ['value' => self::STRATEGY_MERGE, 'label' => 'Merge']; + break; + case self::STRATEGY_SUBTRACT: + $availableStrategies[] = ['value' => self::STRATEGY_SUBTRACT, 'label' => 'Subtract']; + break; + } } + return $availableStrategies; } @@ -262,11 +243,38 @@ public function getSupportedStrategiesForField(FieldInterface $field) * Retrieves the processor for a type of element. The processor determines how to do things like get the field * layout. * @param $elementType - * @return AbstractElementTypeProcessor + * @return string processor classname * @throws \ReflectionException */ - public function getElementTypeProcessor($elementType) + public function getElementTypeProcessor($elementType): ?string { + $processors = $this->getElementTypeProcessors(); + + $processorsKeyedByClass = []; + foreach ($processors as $processor) { + $reflection = new \ReflectionClass($processor); + /** @var AbstractElementTypeProcessor $instance */ + $instance = $reflection->newInstanceWithoutConstructor(); + $type = $instance::getType(); + $processorsKeyedByClass[$type] = $processor; + } + + if (array_key_exists($elementType, $processorsKeyedByClass)) { + return $processorsKeyedByClass[$elementType]; + } + return null; + } + + /** + * Gets an array of all element type processors + * @return array + */ + public function getElementTypeProcessors(): array + { + if (self::$_ELEMENT_TYPE_PROCESSORS !== null) { + return self::$_ELEMENT_TYPE_PROCESSORS; + } + $processors = [ EntryProcessor::class, UserProcessor::class, @@ -281,20 +289,62 @@ public function getElementTypeProcessor($elementType) $event = new RegisterComponentTypesEvent(); $event->types = &$processors; $this->trigger(self::EVENT_REGISTER_ELEMENT_PROCESSORS, $event); + self::$_ELEMENT_TYPE_PROCESSORS = $processors; + return $processors; + } + + /** + * Retrieves the processor for a type of field + * @param FieldInterface $fieldType + * @param $strategy + * @return AbstractFieldProcessor|null field processor class + * @throws \ReflectionException + */ + public function getFieldProcessor(FieldInterface $fieldType, $strategy = null): ?AbstractFieldProcessor + { + $processors = $this->getFieldProcessors(); - $processorsKeyedByClass = []; foreach ($processors as $processor) { $reflection = new \ReflectionClass($processor); - /** @var AbstractElementTypeProcessor $instance */ + /** @var AbstractFieldProcessor $instance */ $instance = $reflection->newInstanceWithoutConstructor(); - $type = $instance::getType(); - $processorsKeyedByClass[$type] = $processor; + + if ($strategy && !in_array($strategy, $instance::getSupportedStrategies(), true)) { + continue; + } + + $fields = $instance::getSupportedFields(); + foreach ($fields as $field) { + if (!$fieldType instanceof $field) { + continue; + } + + return $instance; + } + } + } - if (array_key_exists($elementType, $processorsKeyedByClass)) { - return $processorsKeyedByClass[$elementType]; + /** + * Gets an array of all field processors + * @return array + */ + public function getFieldProcessors(): array + { + if (self::$_FIELD_TYPE_PROCESSORS !== null) { + return self::$_FIELD_TYPE_PROCESSORS; } - return null; + $processors = [ + PlainTextProcessor::class, + RelationFieldProcessor::class + ]; + + $event = new RegisterComponentTypesEvent(); + $event->types = &$processors; + $this->trigger(self::EVENT_REGISTER_FIELD_PROCESSORS, $event); + + self::$_FIELD_TYPE_PROCESSORS = $processors; + return $processors; } /** @@ -304,10 +354,11 @@ public function getElementTypeProcessor($elementType) * @param $elementIds * @param $fieldIds * @param $keyedFieldValues - * @throws \yii\db\Exception + * @param $fieldStrategies * @throws \ReflectionException + * @throws \yii\db\Exception */ - public function saveContext($elementType, $siteId, $elementIds, $fieldIds, $keyedFieldValues) + public function saveContext($elementType, $siteId, $elementIds, $fieldIds, $keyedFieldValues, $fieldStrategies): void { /** @var AbstractElementTypeProcessor $processor */ $processor = $this->getElementTypeProcessor($elementType); @@ -327,7 +378,7 @@ public function saveContext($elementType, $siteId, $elementIds, $fieldIds, $keye $rows = []; foreach ($elementIds as $elementId) { foreach ($fieldIds as $fieldId) { - $strategy = $fieldStrategies[$fieldId] ?? 'replace'; + $strategy = $fieldStrategies[$fieldId] ?? self::STRATEGY_REPLACE; $rows[] = [ 'pending',