diff --git a/src/Keystone.php b/src/Keystone.php index 266ac2f..516ff9c 100644 --- a/src/Keystone.php +++ b/src/Keystone.php @@ -4,11 +4,15 @@ use Craft; use craft\base\Plugin; +use craft\services\Elements; use craft\services\Fields; use craft\web\Application as WebApplication; +use craft\web\Response; use craft\web\UrlManager; use markhuot\keystone\listeners\AddBodyParamObjectBehavior; use markhuot\keystone\actions\GetComponentType; +use markhuot\keystone\listeners\AfterPropagateElement; +use markhuot\keystone\listeners\OverrideDraftResponseWithFieldHtml; use markhuot\keystone\listeners\RegisterCpUrlRules; use markhuot\keystone\listeners\RegisterDefaultComponentTypes; use markhuot\keystone\listeners\RegisterKeystoneFieldType; @@ -30,6 +34,7 @@ public function init(): void [Fields::class, Fields::EVENT_REGISTER_FIELD_TYPES, RegisterKeystoneFieldType::class], [UrlManager::class, UrlManager::EVENT_REGISTER_CP_URL_RULES, RegisterCpUrlRules::class], [GetComponentType::class, GetComponentType::EVENT_REGISTER_COMPONENT_TYPES, RegisterDefaultComponentTypes::class], + [Response::class, Response::EVENT_AFTER_PREPARE, OverrideDraftResponseWithFieldHtml::class], ); } } diff --git a/src/actions/AddComponent.php b/src/actions/AddComponent.php index 813323c..39b82cc 100644 --- a/src/actions/AddComponent.php +++ b/src/actions/AddComponent.php @@ -5,6 +5,8 @@ use craft\helpers\Db; use craft\helpers\StringHelper; use markhuot\keystone\db\Table; +use markhuot\keystone\models\Component; +use markhuot\keystone\models\ComponentData; class AddComponent { @@ -15,21 +17,23 @@ public function handle( string $path, string $slot, string $type - ) { - $level = count(explode('/', $path)); - $date = Db::prepareDateForDb(new \DateTime); + ): Component { + $componentData = new ComponentData; + $componentData->type = $type; + $componentData->save(); - \Craft::$app->getDb()->createCommand()->insert(Table::COMPONENTS, [ - 'elementId' => $elementId, - 'fieldId' => $fieldId, - 'type' => $type, - 'sortOrder' => $sortOrder, - 'path' => $path, - 'level' => $level, - 'slot' => $slot, - 'dateCreated' => $date, - 'dateUpdated' => $date, - 'uid' => StringHelper::UUID(), - ]); + $component = new Component; + $component->elementId = $elementId; + $component->fieldId = $fieldId; + $component->dataId = $componentData->id; + $component->path = $path; + $component->slot = $slot; + $component->type = $type; + $component->sortOrder = $sortOrder; + $component->save(); + + $component->refresh(); + + return $component; } } diff --git a/src/actions/DuplicateComponentTree.php b/src/actions/DuplicateComponentTree.php new file mode 100644 index 0000000..684510b --- /dev/null +++ b/src/actions/DuplicateComponentTree.php @@ -0,0 +1,76 @@ +where([ + 'elementId' => $source->id, + 'fieldId' => $field->id, + ])->orderBy(['path' => 'asc']); + + foreach ($query->each() as $component) { + $duplicate = new Component; + $duplicate->id = $component->id; + $duplicate->elementId = $destination->id; + $duplicate->fieldId = $field->id; + $duplicate->dataId = $component->dataId; + $duplicate->sortOrder = $component->sortOrder; + $duplicate->path = $this->remapPath($component->path); + $duplicate->level = $component->level; + $duplicate->slot = $component->slot; + $duplicate->dateCreated = DateTimeHelper::now(); + $duplicate->dateUpdated = DateTimeHelper::now(); + $duplicate->uid = StringHelper::UUID(); + $duplicate->save(); + + static::$mapping[$component->id] = $duplicate->id; + } + + // I'd love to use INSERT INTO components (elementId, fieldId, ...) SELECT * FROM components WHERE elementId= and fieldId= + // but Craft breaks this because they subclass the db->createCommand() and don't allow you to pass + // a query as the second parameter. They look for $columns[dateCreated] on that second param which + // works when setting raw values but not if a query is passed because it tries to $query[dateCreated] + // and Query isn't array-accessible. + // + // $query = Component::find()->select(['fieldId', 'componentId', 'sortOrder', 'path', 'level', 'slot'])->where([ + // 'elementId' => $source->id, + // 'fieldId' => $field->id, + // ]); + // $params = [ + // 'elementId' => $source->id, + // 'fieldId' => $field->id, + // ]; + // Craft::$app->db->createCommand(Craft::$app->db->getQueryBuilder()->insert(Table::COMPONENTS, $query, $params))->execute(); + // Craft::$app->db->createCommand()->insert(Table::COMPONENTS, $query); + } + + protected function remapPath(?string $path) + { + if ($path === null) { + return null; + } + + return collect(explode('/', $path)) + ->map(function ($segment) use ($path) { + if (! isset(static::$mapping[$segment])) { + throw new \RuntimeException('Could not remap ' . $path . ' because ' . $segment . ' could not be found'); + } + + return static::$mapping[$segment]; + }) + ->join('/'); + } +} diff --git a/src/actions/EditComponentData.php b/src/actions/EditComponentData.php new file mode 100644 index 0000000..28f1cb7 --- /dev/null +++ b/src/actions/EditComponentData.php @@ -0,0 +1,17 @@ +data->merge($data); + $component->data->save(); + $component->refresh(); + + return $component; + } +} diff --git a/src/actions/MoveComponent.php b/src/actions/MoveComponent.php new file mode 100644 index 0000000..111d049 --- /dev/null +++ b/src/actions/MoveComponent.php @@ -0,0 +1,57 @@ + new Expression('sortOrder - 1') + ], ['and', + ['=', 'elementId', $source->elementId], + ['=', 'fieldId', $source->fieldId], + ['path' => $source->path], + ['>', 'sortOrder', $source->sortOrder] + ]); + + // Refresh our target to get the updated/correct sortOrder + $target->refresh(); + + // make room for the insertion + if ($position === 'above') { + Component::updateAll([ + 'sortOrder' => new Expression('sortOrder + 1') + ], ['and', + ['=', 'elementId', $target->elementId], + ['=', 'fieldId', $target->fieldId], + ['path' => $target->path], + ['>=', 'sortOrder', $target->sortOrder] + ]); + } + if ($position === 'below') + { + Component::updateAll([ + 'sortOrder' => new Expression('sortOrder + 1') + ], ['and', + ['=', 'elementId', $target->elementId], + ['=', 'fieldId', $target->fieldId], + ['path' => $target->path], + ['>', 'sortOrder', $target->sortOrder] + ]); + } + + // Refresh the target again, in case it changed, so we're setting the correct + // sort order + $target->refresh(); + $source->refresh(); + + $source->path = $target->path; + $source->sortOrder = $position == 'above' ? $target->sortOrder - 1 : $target->sortOrder + 1; + $source->save(); + } +} diff --git a/src/controllers/ComponentsController.php b/src/controllers/ComponentsController.php index f849706..d947003 100644 --- a/src/controllers/ComponentsController.php +++ b/src/controllers/ComponentsController.php @@ -21,6 +21,7 @@ public function actionAdd() $field = Craft::$app->fields->getFieldById($fieldId); $path = $this->request->getQueryParam('path'); $slot = $this->request->getQueryParam('slot'); + $sortOrder = $this->request->getRequiredQueryParam('sortOrder'); return $this->asCpScreen() ->title('Add component') @@ -31,46 +32,24 @@ public function actionAdd() 'path' => $path, 'slot' => $slot, 'types' => (new GetComponentType())->all(), + 'sortOrder' => $sortOrder, ]); } public function actionStore() { - $componentData = new ComponentData; - $componentData->type = $this->request->getRequiredBodyParam('type'); - $componentData->save(); - - $component = new Component; - $component->elementId = $elementId = $this->request->getRequiredBodyParam('elementId'); - $component->fieldId = $fieldId = $this->request->getRequiredBodyParam('fieldId'); - $component->dataId = $componentData->id; - $component->path = $path = $this->request->getBodyParam('path'); - $component->slot = $this->request->getBodyParam('slot'); - $component->type = $this->request->getRequiredBodyParam('type'); - $component->sortOrder = ((Component::find()->where([ - 'elementId' => $elementId, - 'fieldId' => $fieldId, - 'path' => $component->path, - 'slot' => $component->slot, - ])->max('sortOrder')) ?? -1) + 1; - $component->save(); - - $element = Craft::$app->elements->getElementById($component->elementId); - $field = Craft::$app->fields->getFieldById($component->fieldId); - - return $component->errors ? - $this->asFailure('Oh no') : - $this->asSuccess('Component added', [ - 'elementId' => $component->elementId, - 'fieldId' => $component->fieldId, - 'fieldHandle' => $field->handle, - 'fieldHtml' => $field->getInputHtml(null, $element), - ]); + return $this->asSuccess('Component added', [ + 'path' => $this->request->getBodyParam('path'), + 'slot' => $this->request->getBodyParam('slot'), + 'type' => $this->request->getBodyParam('type'), + 'sortOrder' => $this->request->getBodyParam('sortOrder'), + ]); } public function actionEdit() { $id = $this->request->getRequiredQueryParam('id'); + $elementId = $this->request->getRequiredQueryParam('elementId'); return $this->asCpScreen() ->title('Edit component') @@ -80,27 +59,30 @@ public function actionEdit() ]) ->action('keystone/components/update') ->contentTemplate('keystone/edit', [ - 'component' => Component::findOne(['id' => $id]), + 'component' => Component::findOne(['id' => $id, 'elementId' => $elementId]), ]); } public function actionUpdate() { - $id = $this->request->getRequiredBodyParam('id'); - $data = $this->request->getBodyParam('fields', []); - - $component = Component::findOne(['id' => $id]); - $component->data->merge($data); - $component->data->save(); - - $element = Craft::$app->elements->getElementById($component->elementId); - $field = Craft::$app->fields->getFieldById($component->fieldId); +// $id = $this->request->getRequiredBodyParam('id'); +// $data = $this->request->getBodyParam('fields', []); +// +// $component = Component::findOne(['id' => $id]); +// $component->data->merge($data); +// $component->data->save(); +// +// $element = Craft::$app->elements->getElementById($component->elementId); +// $field = Craft::$app->fields->getFieldById($component->fieldId); return $this->asSuccess('Component saved', [ - 'elementId' => $component->elementId, - 'fieldId' => $component->fieldId, - 'fieldHandle' => $field->handle, - 'fieldHtml' => $field->getInputHtml(null, $element), + 'id' => $this->request->getRequiredBodyParam('id'), + 'elementId' => $this->request->getRequiredBodyParam('elementId'), + 'fields' => $this->request->getRequiredBodyParam('fields'), +// 'elementId' => $component->elementId, +// 'fieldId' => $component->fieldId, +// 'fieldHandle' => $field->handle, +// 'fieldHtml' => $field->getInputHtml(null, $element), ]); } diff --git a/src/fields/Keystone.php b/src/fields/Keystone.php index 9f2dbd9..6673025 100644 --- a/src/fields/Keystone.php +++ b/src/fields/Keystone.php @@ -6,13 +6,28 @@ use craft\base\ElementInterface; use craft\base\Field; use craft\web\View; +use markhuot\keystone\actions\AddComponent; +use markhuot\keystone\actions\DuplicateComponentTree; +use markhuot\keystone\actions\EditComponentData; use markhuot\keystone\actions\GetComponentType; +use markhuot\keystone\actions\MoveComponent; +use markhuot\keystone\listeners\OverrideDraftResponseWithFieldHtml; use markhuot\keystone\models\Component; -use markhuot\keystone\models\ComponentElement; use Twig\Markup; class Keystone extends Field { + /** + * @inheritDoc + */ + public static function hasContentColumn(): bool + { + return false; + } + + /** + * Gets a fragment containing all the components of the current element for this field instance + */ protected function getFragment(ElementInterface $element) { $component = new Component; @@ -25,6 +40,44 @@ protected function getFragment(ElementInterface $element) return $component; } + /** + * @inheritDoc + */ + public function normalizeValueFromRequest(mixed $value, ?ElementInterface $element = null): mixed + { + if (($value['action'] ?? false) === false) { + return null; + } + + $payload = json_decode($value['action'], true, 512, JSON_THROW_ON_ERROR); + + if ($payload['name'] === 'add-component') { + ['sortOrder' => $sortOrder, 'path' => $path, 'slot' => $slot, 'type' => $type] = $payload; + (new AddComponent)->handle($element->id, $this->id, $sortOrder, $path, $slot, $type); + OverrideDraftResponseWithFieldHtml::override($element, $this); + } + + if ($payload['name'] === 'move-component') { + ['source' => $sourceId, 'target' => $targetId, 'position' => $position] = $payload; + $source = Component::findOne(['id' => $sourceId, 'elementId' => $element->id]); + $target = Component::findOne(['id' => $targetId, 'elementId' => $element->id]); + (new MoveComponent)->handle($source, $target, $position); + OverrideDraftResponseWithFieldHtml::override($element, $this); + } + + if ($payload['name'] === 'edit-component') { + ['id' => $id, 'elementId' => $elementId, 'fields' => $fields] = $payload; + $component = Component::findOne(['id' => $id, 'elementId' => $elementId]); + (new EditComponentData)->handle($component, $fields); + OverrideDraftResponseWithFieldHtml::override($element, $this); + } + + return null; + } + + /** + * @inheritDoc + */ protected function inputHtml(mixed $value, ?ElementInterface $element = null): string { return Craft::$app->getView()->renderTemplate('keystone/field', [ @@ -35,8 +88,25 @@ protected function inputHtml(mixed $value, ?ElementInterface $element = null): s ]); } + /** + * @inheritDoc + */ public function normalizeValue(mixed $value, ?ElementInterface $element = null): mixed { return $this->getFragment($element); } + + /** + * @inheritDoc + */ + public function afterElementSave(ElementInterface $element, bool $isNew): void + { + // If we're duplicating an element to create a draft or revision, duplicate the component + // tree as well + if ($element->duplicateOf && $isNew) { + (new DuplicateComponentTree)->handle($element->duplicateOf, $element, $this); + } + + parent::afterElementSave($element, $isNew); + } } diff --git a/src/listeners/AfterPropagateElement.php b/src/listeners/AfterPropagateElement.php new file mode 100644 index 0000000..9654d18 --- /dev/null +++ b/src/listeners/AfterPropagateElement.php @@ -0,0 +1,13 @@ +getFieldLayout()->createForm($element)->tabs as $tab) { + if (!$tab->getUid()) { + continue; + } + + foreach ($tab->elements as [$fieldLayout, $isConditional, $fieldHtml]) { + if ($fieldLayout instanceof CustomField) { + if ($fieldLayout->getField()->handle === $field->handle) { + static::$override[] = [ + 'elementId' => $element->id, + 'tabUid' => $tab->uid, + 'fieldUid' => $fieldLayout->uid, + 'fieldHtml' => $fieldHtml, + ]; + } + } + } + } + } + + public function handle(Event $event) + { + if (\Craft::$app->request->getBodyParam('action') !== 'elements/save-draft') { + return; + } + + /** @var \craft\web\Response $response */ + $response = $event->sender; + $json = json_decode($response->content, true, 512, JSON_THROW_ON_ERROR); + + foreach ($json['missingElements'] as $tabIndex => $missingTab) { + $tabOverrides = collect(static::$override)->where(fn ($override) => $override['tabUid'] === $missingTab['uid']); + if ($tabOverrides->count()) { + foreach ($missingTab['elements'] as $fieldIndex => $missingField) { + $fieldOverrides = $tabOverrides->where(fn ($override) => $override['fieldUid'] === $missingField['uid']); + if ($fieldOverrides->count()) { + if ($fieldOverrides->first()['elementId'] === $json['element']['id']) { + // $json['missingElements'][0]['elements'][1]['html'] = '