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'] = '
' . + // $fieldOverrides->first()['fieldHtml'] + // . '
'; + $json['missingElements'][$tabIndex]['elements'][$fieldIndex]['html'] = $fieldOverrides->first()['fieldHtml']; + } + } + } + } + } + + $response->content = json_encode($json); + } +} diff --git a/src/migrations/Install.php b/src/migrations/Install.php index ca8ab13..fb4df24 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -19,10 +19,10 @@ public function safeUp() ]); $this->createTable(Table::COMPONENTS, [ - 'id' => $this->primaryKey(), - 'elementId' => $this->bigInteger()->unsigned(), - 'fieldId' => $this->bigInteger()->unsigned(), - 'dataId' => $this->bigInteger()->unsigned(), + 'id' => $this->integer(), + 'elementId' => $this->integer(), + 'fieldId' => $this->integer(), + 'dataId' => $this->integer(), 'sortOrder' => $this->integer(), 'path' => $this->string(1024), 'level' => $this->integer(), @@ -32,6 +32,11 @@ public function safeUp() 'uid' => $this->uid(), ]); + $this->createIndex(null, Table::COMPONENTS, ['id', 'elementId']); + $this->addForeignKey(null, Table::COMPONENTS, ['elementId'], \craft\db\Table::ELEMENTS, ['id'], 'CASCADE', null); + $this->addForeignKey(null, Table::COMPONENTS, ['fieldId'], \craft\db\Table::FIELDS, ['id'], 'CASCADE', null); + $this->addForeignKey(null, Table::COMPONENTS, ['dataId'], Table::COMPONENT_DATA, ['id'], 'CASCADE', null); + return true; } diff --git a/src/models/Component.php b/src/models/Component.php index b0f4a3e..9c95c31 100644 --- a/src/models/Component.php +++ b/src/models/Component.php @@ -24,6 +24,11 @@ public function getData() return $this->hasOne(ComponentData::class, ['id' => 'dataId']); } + public static function primaryKey() + { + return ['id', 'elementId']; + } + public function __get($name) { $value = parent::__get($name); @@ -151,6 +156,8 @@ protected function prepareForDb(): void { parent::prepareForDb(); + $max = Component::find()->max('id') ?? 0; + $this->id = $this->id ?? ($max + 1); $this->level = count(array_filter(explode('/', $this->path))); } } diff --git a/src/resources/keystone.js b/src/resources/keystone.js index b085252..b603569 100644 --- a/src/resources/keystone.js +++ b/src/resources/keystone.js @@ -5,13 +5,28 @@ document.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); - const id = event.target.dataset.keystoneComponentId; - const slideout = new Craft.CpScreenSlideout('keystone/components/edit', {params: {id}}); - - slideout.on('submit', ev => { - //Craft.cp.$primaryForm.append(Object.assign(document.createElement('input'), {name: 'fields[myKeystoneField]', value: new Date().getTime()})) - Craft.cp.$primaryForm.get(0).querySelector('[data-attribute="myKeystoneField"] .input').innerHTML = ev.response.data.fieldHtml + const field = event.target.closest('.field[data-type]'); + if (field.dataset.type !== 'markhuot\\keystone\\fields\\Keystone') { + throw Error('oh no'); + } + const handle = field.dataset.attribute; + + const params = JSON.parse(event.target.dataset.openKeystoneComponentEditor); + const slideout = new Craft.CpScreenSlideout('keystone/components/edit', {params}); + + slideout.on('submit', event => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'fields[' + handle + '][action]'; + input.value = JSON.stringify({ + name: 'edit-component', + id: event.response.data.id, + elementId: event.response.data.elementId, + fields: event.response.data.fields, + }); + Craft.cp.$primaryForm.get(0).appendChild(input); + Craft.cp.$primaryForm.get(0).click(); }); slideout.on('close', () => { @@ -27,11 +42,28 @@ document.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); + const field = event.target.closest('.field[data-type]'); + if (field.dataset.type !== 'markhuot\\keystone\\fields\\Keystone') { + throw Error('oh no'); + } + const handle = field.dataset.attribute; + const params = JSON.parse(event.target.dataset.openKeystoneComponentSelector); const slideout = new Craft.CpScreenSlideout('keystone/components/add', {params}); - slideout.on('submit', ev => { - Craft.cp.$primaryForm.get(0).querySelector('[data-attribute="myKeystoneField"] .input').innerHTML = ev.response.data.fieldHtml + slideout.on('submit', event => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'fields[' + handle + '][action]'; + input.value = JSON.stringify({ + name: 'add-component', + sortOrder: event.response.data.sortOrder, + path: event.response.data.path, + slot: event.response.data.slot, + type: event.response.data.type, + }); + Craft.cp.$primaryForm.get(0).appendChild(input); + Craft.cp.$primaryForm.get(0).click(); }); slideout.on('close', () => { @@ -42,12 +74,13 @@ document.addEventListener('click', event => { const pointer = document.createElement('div'); pointer.id = 'pointer'; pointer.style.display = 'none'; -pointer.style.position = 'absolute'; +pointer.style.position = 'fixed'; pointer.style.height = '5px'; pointer.style.width = '400px'; pointer.style.borderRadius = '5px'; pointer.style.background = 'linear-gradient(to right, rgba(31, 95, 234, 1), rgba(31, 95, 234, 0)'; pointer.style.transition = 'all 0.2s'; +pointer.style.zIndex = 110; document.body.appendChild(pointer); let target = undefined; document.addEventListener('mousedown', event => { @@ -58,6 +91,8 @@ document.addEventListener('dragstart', event => { const handle = event.target.querySelector('[data-draggable-handle]'); if (handle.contains(target) || target === handle) { event.target.querySelector('.foo').style.display = 'none'; + event.dataTransfer.setData('keystone/id', event.target.dataset.draggable); + event.dataTransfer.setData('keystone/id/' + event.target.dataset.draggable, event.target.dataset.draggable); } else { event.preventDefault(); @@ -71,6 +106,11 @@ document.addEventListener('dragover', event => { return; } + if (event.dataTransfer.types.includes('keystone/id/' + el.dataset.draggable)) { + pointer.style.display = 'none'; + return; + } + const row = el.querySelector('[data-draggable-row]'); const mouse = event.pageY; const { top, height, left } = row.getBoundingClientRect(); @@ -92,12 +132,25 @@ document.addEventListener('dragover', event => { }); document.addEventListener('dragend', async event => { - const response = await Craft.postActionRequest('keystone/components/move', { - source: event.target.dataset.draggable, - target: dropTarget.el.dataset.draggable, + const field = event.target.closest('.field[data-type]'); + if (field.dataset.type !== 'markhuot\\keystone\\fields\\Keystone') { + throw Error('oh no'); + } + const handle = field.dataset.attribute; + const source = event.target.dataset.draggable; + const target = dropTarget.el.dataset.draggable; + + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'fields[' + handle + '][action]'; + input.value = JSON.stringify({ + name: 'move-component', + source, + target, position: dropTarget.position, }); - Craft.cp.$primaryForm.get(0).querySelector('[data-attribute="myKeystoneField"] .input').innerHTML = response.fieldHtml - Craft.cp.displaySuccess(response.message); + Craft.cp.$primaryForm.get(0).appendChild(input); + Craft.cp.$primaryForm.get(0).click(); + pointer.style.display = 'none'; }); diff --git a/src/templates/edit.twig b/src/templates/edit.twig index ab10715..1f7f1aa 100644 --- a/src/templates/edit.twig +++ b/src/templates/edit.twig @@ -2,6 +2,7 @@
{{ hiddenInput('id', component.id) }} + {{ hiddenInput('elementId', component.elementId) }} {% namespace 'fields' %} {% for field in component.getType().getSchema().fields %} diff --git a/src/templates/field.twig b/src/templates/field.twig index 0150751..a45f515 100644 --- a/src/templates/field.twig +++ b/src/templates/field.twig @@ -6,14 +6,15 @@ {% macro layer(component, element, field) %} -