diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 1b3e533..e047087 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -143,9 +143,7 @@ jobs: uses: actions/cache@v3 with: path: seed.sql - key: ${{ runner.os }}-seed-${{ hashFiles('config/project/project.yaml') }} - restore-keys: | - ${{ runner.os }}-seed- + key: ${{ runner.os }}-seed-${{ hashFiles('config/project/project.yaml', 'src/migrations/*.php') }} - name: Install Craft if: steps.database-cache.outputs.cache-hit != 'true' run: | @@ -160,4 +158,4 @@ jobs: - name: Install plugins run: ./bin/post-install.sh - name: Run test suite - run: ./vendor/bin/pest + run: ./vendor/bin/pest -vvv diff --git a/src/actions/GetComponentType.php b/src/actions/GetComponentType.php index c1c77be..d729f7d 100644 --- a/src/actions/GetComponentType.php +++ b/src/actions/GetComponentType.php @@ -53,7 +53,7 @@ public function byType(string $type): ComponentType $fqcn = $className; } - if ($fqcn) { + if (! empty($fqcn)) { return Craft::$container->get($fqcn, ['context' => $this->context]); } diff --git a/src/assetbundles/KeystoneAssetBundle.php b/src/assetbundles/KeystoneAssetBundle.php index fe07e6d..c0f0d28 100644 --- a/src/assetbundles/KeystoneAssetBundle.php +++ b/src/assetbundles/KeystoneAssetBundle.php @@ -14,12 +14,11 @@ public function init() $this->depends = []; $this->js = [ - 'components/create.js', - 'components/edit.js', 'components/drag.js', 'lib/alpine.min.js', 'lib/axios.min.js', - 'components/alpine.js', + 'components/post.js', + 'components/slideout.js', ]; $this->css = [ diff --git a/src/base/ComponentType.php b/src/base/ComponentType.php index c1a47be..450a13d 100644 --- a/src/base/ComponentType.php +++ b/src/base/ComponentType.php @@ -77,7 +77,10 @@ public function render(array $variables = []): string abstract public function getTemplatePath(): string; - public function getSlotDefinitions() + /** + * @return Collection + */ + public function getSlotDefinitions(): Collection { return $this->getSchema()[1]; } diff --git a/src/base/SlotDefinition.php b/src/base/SlotDefinition.php index ade1f90..15c3727 100644 --- a/src/base/SlotDefinition.php +++ b/src/base/SlotDefinition.php @@ -8,6 +8,8 @@ class SlotDefinition { + protected bool $collapsed = false; + public function __construct( protected ?Component $component = null, protected ?string $name = null, @@ -43,6 +45,18 @@ public function defaults(array $componentConfig): self return $this; } + public function collapsed(bool $collapsed = true): self + { + $this->collapsed = $collapsed; + + return $this; + } + + public function isCollapsed(): bool + { + return $this->collapsed; + } + public function allows(string $type): bool { if (! empty($this->whitelist)) { diff --git a/src/controllers/ComponentsController.php b/src/controllers/ComponentsController.php index 6e6a3de..e7f300c 100644 --- a/src/controllers/ComponentsController.php +++ b/src/controllers/ComponentsController.php @@ -83,7 +83,9 @@ public function actionUpdate() (new EditComponentData)->handle($component, $fields); - return $this->asSuccess('Component saved'); + return $this->asSuccess('Component saved', [ + 'fieldHtml' => $component->getElement()->getFieldHtml($component->getField()), + ]); } public function actionDelete() @@ -105,4 +107,23 @@ public function actionMove() 'fieldHtml' => $data->getTargetElement()->getFieldHtml($data->getTargetField()), ]); } + + public function actionToggleDisclosure() + { + /** @var Component $component */ + $component = $this->request->getQueryParamObjectOrFail(Component::class); + $defns = $component->getType()->getSlotDefinitions(); + $defaultState = $defns->every(fn ($d) => $d->isCollapsed()) ? 'closed' : 'open'; + $state = $component->disclosure->state ?? $defaultState; + $newState = $state === 'open' ? 'closed' : 'open'; + + if ($newState === $defaultState) { + $component->disclosure->delete(); + } else { + $component->disclosure->state = $newState; + $component->disclosure->save(); + } + + return $this->asSuccess(); + } } diff --git a/src/db/Table.php b/src/db/Table.php index b182da6..ea08869 100644 --- a/src/db/Table.php +++ b/src/db/Table.php @@ -7,4 +7,6 @@ class Table const COMPONENTS = '{{%keystone_components}}'; const COMPONENT_DATA = '{{%keystone_component_data}}'; + + const COMPONENT_DISCLOSURES = '{{%keystone_component_disclosure}}'; } diff --git a/src/fields/Keystone.php b/src/fields/Keystone.php index ce9a481..599c4c2 100644 --- a/src/fields/Keystone.php +++ b/src/fields/Keystone.php @@ -42,7 +42,7 @@ protected function inputHtml(mixed $value, ElementInterface $element = null): st return Craft::$app->getView()->renderTemplate('keystone/field', [ 'element' => $element, 'field' => $this, - 'component' => $this->getFragment($element), + 'component' => $this->getFragment($element)->withDisclosures(), 'getComponentTypes' => new GetComponentType, ]); } diff --git a/src/listeners/RegisterDefaultComponentTypes.php b/src/listeners/RegisterDefaultComponentTypes.php index 60a4ca1..ed4245f 100644 --- a/src/listeners/RegisterDefaultComponentTypes.php +++ b/src/listeners/RegisterDefaultComponentTypes.php @@ -9,8 +9,8 @@ class RegisterDefaultComponentTypes public function handle(RegisterComponentTypes $event): void { $event->registerTwigTemplate('keystone/asset', 'cp:keystone/components/asset.twig'); - $event->registerTwigTemplate('keystone/elementquery', 'cp:keystone/components/elementquery.twig'); $event->registerTwigTemplate('keystone/entry', 'cp:keystone/components/entry.twig'); + $event->registerTwigTemplate('keystone/entryquery', 'cp:keystone/components/entryquery.twig'); $event->registerTwigTemplate('keystone/fragment', 'cp:keystone/components/fragment.twig'); $event->registerTwigTemplate('keystone/heading', 'cp:keystone/components/heading.twig'); $event->registerTwigTemplate('keystone/icon', 'cp:keystone/components/icon.twig'); diff --git a/src/migrations/Install.php b/src/migrations/Install.php index fb4df24..d0f4d00 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -32,16 +32,29 @@ public function safeUp() 'uid' => $this->uid(), ]); + $this->createTable(Table::COMPONENT_DISCLOSURES, [ + 'id' => $this->primaryKey(), + 'userId' => $this->integer(), + 'componentId' => $this->integer(), + 'state' => $this->enum('state', ['open', 'closed']), + 'dateCreated' => $this->dateTime()->notNull(), + 'dateUpdated' => $this->dateTime()->notNull(), + '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); + $this->addForeignKey(null, Table::COMPONENT_DISCLOSURES, ['userId'], \craft\db\Table::USERS, ['id'], 'CASCADE', null); + $this->addForeignKey(null, Table::COMPONENT_DISCLOSURES, ['componentId'], Table::COMPONENTS, ['id'], 'CASCADE', null); return true; } public function safeDown() { + $this->dropTableIfExists(Table::COMPONENT_DISCLOSURES); $this->dropTableIfExists(Table::COMPONENTS); $this->dropTableIfExists(Table::COMPONENT_DATA); diff --git a/src/models/Component.php b/src/models/Component.php index 3b70766..672dbf6 100644 --- a/src/models/Component.php +++ b/src/models/Component.php @@ -47,6 +47,8 @@ class Component extends ActiveRecord protected ?ComponentType $_type = null; + protected bool $withDisclosures = false; + public static function factory(): \markhuot\keystone\factories\Component { return new \markhuot\keystone\factories\Component; @@ -73,6 +75,33 @@ public function getData(): ActiveQuery return $this->hasOne(ComponentData::class, ['id' => 'dataId']); } + public function getDisclosure(): ActiveQuery + { + $query = $this->hasOne(ComponentDisclosure::class, ['componentId' => 'id']); + + if (app()->getUser()->getIdentity()) { + $query->where(['userId' => app()->getUser()->getIdentity()->id]); + } + + return $query; + } + + public function withDisclosures(bool $withDisclosures = true): self + { + $this->withDisclosures = $withDisclosures; + + return $this; + } + + public function isCollapsed(): bool + { + $shouldBeClosed = $this->getType()->getSlotDefinitions()->every(fn ($defn) => $defn->isCollapsed()); + $notForcedOpen = $this->disclosure->state !== 'open'; + $forcedClosed = $this->disclosure->state == 'closed'; + + return ($shouldBeClosed && $notForcedOpen) || $forcedClosed; + } + /** * @return array */ @@ -124,6 +153,13 @@ public function __get($name) $value = $data; } + if ($name === 'disclosure' && $value === null) { + $this->populateRelation($name, $data = new ComponentDisclosure); + $data->userId = app()->getUser()->getIdentity()->id; + $data->componentId = $this->id; + $value = $data; + } + if ($name === 'data' && $value instanceof ComponentData) { $value->setNormalizer((new NormalizeFieldDataForComponent($this))->handle(...)); } @@ -286,20 +322,20 @@ public function getSlot(string $name = null): SlotCollection if ($this->slotted === null && $this->elementId && $this->fieldId) { $components = Component::find() - ->with('data') + ->with(array_filter(['data', $this->withDisclosures ? 'disclosure' : null])) ->where(['and', ['elementId' => $this->elementId], ['fieldId' => $this->fieldId], + new OrCondition(array_filter([ + ! $this->getChildPath() ? ['path' => null] : null, + ['like', 'path', $this->getChildPath().'%', false], + ])), // this is intentionally left out. We don't want to limit our query by slot name // because children of this component may not share the same name. We need to pull // all children out of the database and then the slot name filtering happens below // before being returned. // ['slot' => $name], - new OrCondition(array_filter([ - ! $this->getChildPath() ? ['path' => null] : null, - ['like', 'path', $this->getChildPath().'%', false], - ])), ]) ->orderBy('sortOrder') ->collect(); diff --git a/src/models/ComponentDisclosure.php b/src/models/ComponentDisclosure.php new file mode 100644 index 0000000..88007b7 --- /dev/null +++ b/src/models/ComponentDisclosure.php @@ -0,0 +1,14 @@ + { - const anchor = event.target.closest && event.target.closest('button[data-open-keystone-component-selector]') - if (!anchor) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const field = anchor.closest('.field[data-type]'); - const layoutElementUid = field.dataset.layoutElement; - if (field.dataset.type !== 'markhuot\\keystone\\fields\\Keystone') { - throw Error('oh no'); - } - const handle = field.dataset.attribute; - - let form = field.closest('form'); - let editor = $.data(form, 'elementEditor'); - - // There might not be an editor if we're in live preview so we need to look around - // in the DOM for the real editor behind the scenes. - if (! editor && form.classList.contains('lp-editor')) { - form = document.getElementById('main-form'); - editor = $.data(form, 'elementEditor'); - } - - await editor.ensureIsDraftOrRevision(); - - const params = JSON.parse(anchor.dataset.openKeystoneComponentSelector); - params.elementId = editor.settings.elementId; - const slideout = new Craft.CpScreenSlideout('keystone/components/add', {params}); - - slideout.on('submit', event => { - const input = form.querySelector('.keystone-pulse') || document.createElement('input'); - input.setAttribute('class', 'keystone-pulse'); - input.setAttribute('type', 'hidden'); - input.setAttribute('name', 'keystone[pulse]'); - input.setAttribute('value', new Date().getTime()); - form.appendChild(input); - - if (event.response.data.fieldHtml) { - const template = document.createElement('div'); - template.innerHTML = event.response.data.fieldHtml; - - document.querySelectorAll(`[data-layout-element="${layoutElementUid}"]`).forEach(el => { - el.innerHTML = template.firstElementChild.innerHTML; - }) - form.click(); - } - - form.click(); - }); - - slideout.on('close', () => { - // ... - }); -}); - -document.addEventListener('dblclick', event => { - const label = event.target.closest && event.target.closest('[data-keystone-select-type]'); - if (! label) { - return; - } - - const form = label.closest('form'); - $(form).data('cpScreen').submit(); -}); diff --git a/src/resources/components/edit.js b/src/resources/components/edit.js deleted file mode 100644 index 1c4a299..0000000 --- a/src/resources/components/edit.js +++ /dev/null @@ -1,57 +0,0 @@ -document.addEventListener('click', async event => { - const anchor = event.target.closest && event.target.closest('a[data-open-keystone-component-editor]') - if (!anchor) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const field = anchor.closest('.field[data-type]'); - const layoutElementUid = field.dataset.layoutElement; - if (field.dataset.type !== 'markhuot\\keystone\\fields\\Keystone') { - throw Error('oh no'); - } - const handle = field.dataset.attribute; - - let form = field.closest('form'); - let editor = $.data(form, 'elementEditor'); - - // There might not be an editor if we're in live preview so we need to look around - // in the DOM for the real editor behind the scenes. - if (! editor && form.classList.contains('lp-editor')) { - form = document.getElementById('main-form'); - editor = $.data(form, 'elementEditor'); - } - - await editor.ensureIsDraftOrRevision(); - - const params = JSON.parse(anchor.dataset.openKeystoneComponentEditor); - params.elementId = editor.settings.elementId; - const slideout = new Craft.CpScreenSlideout('keystone/components/edit', {params}); - - slideout.on('submit', event => { - const input = form.querySelector('.keystone-pulse') || document.createElement('input'); - input.setAttribute('class', 'keystone-pulse'); - input.setAttribute('type', 'hidden'); - input.setAttribute('name', 'keystone[pulse]'); - input.setAttribute('value', new Date().getTime()); - form.appendChild(input); - - if (event.response.data.fieldHtml) { - const template = document.createElement('div'); - template.innerHTML = event.response.data.fieldHtml; - - document.querySelectorAll(`[data-layout-element="${layoutElementUid}"]`).forEach(el => { - el.innerHTML = template.firstElementChild.innerHTML; - }) - form.click(); - } - - form.click(); - }); - - slideout.on('close', () => { - // ... - }); -}); diff --git a/src/resources/components/alpine.js b/src/resources/components/post.js similarity index 86% rename from src/resources/components/alpine.js rename to src/resources/components/post.js index 385f4d2..0a6eb0a 100644 --- a/src/resources/components/alpine.js +++ b/src/resources/components/post.js @@ -10,7 +10,7 @@ window.post = function (action, config={}) { } const headers = { 'X-CSRF-Token': Craft.csrfTokenValue, - 'X-Craft-Namespace': form ? $(form).data('cpScreen').namespace : null, + 'X-Craft-Namespace': form && $(form).data('cpScreen') ? $(form).data('cpScreen').namespace : null, }; const response = await axios({ method: 'post', @@ -19,6 +19,10 @@ window.post = function (action, config={}) { data }); + if (response.data.message) { + Craft.cp.displayNotice(response.data.message); + } + for (const then of thens) { then(response); } diff --git a/src/resources/components/slideout.js b/src/resources/components/slideout.js new file mode 100644 index 0000000..eec6a46 --- /dev/null +++ b/src/resources/components/slideout.js @@ -0,0 +1,45 @@ +window.slideout = function (action, params={}) { + const thens = []; + + async function doSlideout(event) { + event.preventDefault(); + + let form = event.target.closest('form'); + let editor = $.data(form, 'elementEditor'); + + // There might not be an editor if we're in live preview so we need to look around + // in the DOM for the real editor behind the scenes. + if (! editor && form.classList.contains('lp-editor')) { + editor = $.data(document.getElementById('main-form'), 'elementEditor'); + } + + await editor.ensureIsDraftOrRevision(); + + params.elementId = editor.settings.elementId; + const slideout = new Craft.CpScreenSlideout(action, {params}); + + slideout.on('submit', event => { + for (const then of thens) { + then(event.response, form); + } + }); + } + + doSlideout.then = function (callback) { + thens.push(callback); + + return doSlideout; + } + + doSlideout.swap = function (selector) { + thens.push((response, form) => { + const fragment = document.createElement('template'); + fragment.innerHTML = response.data.fieldHtml; + form.querySelector(selector).replaceWith(fragment.content.querySelector(selector)); + }); + + return doSlideout; + } + + return doSlideout; +} diff --git a/src/templates/asset/embed.twig b/src/templates/asset/embed.twig new file mode 100644 index 0000000..90700f5 --- /dev/null +++ b/src/templates/asset/embed.twig @@ -0,0 +1,3 @@ +{% if asset|default(false) %} + +{% endif %} diff --git a/src/templates/components/asset.twig b/src/templates/components/asset.twig index 28218af..b5f5de8 100644 --- a/src/templates/components/asset.twig +++ b/src/templates/components/asset.twig @@ -9,6 +9,11 @@ } %} {% export summary = props.asset.one().title|default %} {% export category = "Data" %} +{% set slot = component.getType.defineSlot() + .defaults([{type: 'keystone/template', data: {template: 'cp:keystone/asset/embed'}}]) + .collapsed() %} {% for asset in props.asset %} - + {% do component.mergeContext({asset: asset, transform: props.transform.value|default(null)}) %} + {{ slot }} + {% do component.mergeContext({asset: null, transform: null}) %} {% endfor %} diff --git a/src/templates/components/entry.twig b/src/templates/components/entry.twig index 58d367a..2d54c3c 100644 --- a/src/templates/components/entry.twig +++ b/src/templates/components/entry.twig @@ -5,7 +5,9 @@ } %} {% export summary = props.entry.one().title|default %} {% export category = "Data" %} -{% set slot = component.getType().defineSlot() %} +{% set slot = component.getType().defineSlot() + .defaults([{type: 'keystone/template', data: {template: 'cp:keystone/entry/link'}}]) + .collapsed() %} {% for entry in props.entry %} {% do component.mergeContext({entry: entry}) %} {{ slot }} diff --git a/src/templates/components/elementquery.twig b/src/templates/components/entryquery.twig similarity index 73% rename from src/templates/components/elementquery.twig rename to src/templates/components/entryquery.twig index fb42762..fe531ef 100644 --- a/src/templates/components/elementquery.twig +++ b/src/templates/components/entryquery.twig @@ -1,12 +1,12 @@ {% export category = "Data" %} -{% export name = "Element Query" %} +{% export name = "Entry Query" %} {% export icon %}{% endexport %} {% export propTypes = { search: field('\\markhuot\\keystone\\fields\\Condition'), limit: field('\\craft\\fields\\Number'), } %} -{% set defaultSlot = component.getType().defineSlot() %} -{% set elements = props.search.limit(props.limit|default(100)).all()|default([]) %} +{% set defaultSlot = component.getType().defineSlot().collapsed() %} +{% set elements = props.search.all()|default([]) %} {% for element in elements %} - {{ defaultSlot.render({element: element}) }} + {{ defaultSlot.render({entry: element}) }} {% endfor %} diff --git a/src/templates/components/template.twig b/src/templates/components/template.twig index d65e068..72cab1b 100644 --- a/src/templates/components/template.twig +++ b/src/templates/components/template.twig @@ -1,3 +1,5 @@ +{% export summary = props.template %} +{% export icon %}{% endexport %} {% if props.template %} {% set parts = props.template|split(':') %} {% if parts|length == 2 %} diff --git a/src/templates/entry/link.twig b/src/templates/entry/link.twig index 5601d97..ad52a09 100644 --- a/src/templates/entry/link.twig +++ b/src/templates/entry/link.twig @@ -1 +1,3 @@ -{{ entry.title }} +{% if entry|default(false) %} + {{ entry.title }} +{% endif %} diff --git a/src/templates/field.twig b/src/templates/field.twig index a504121..bcab5a7 100644 --- a/src/templates/field.twig +++ b/src/templates/field.twig @@ -15,7 +15,7 @@ {% endif %}