Revert button not reverting unsaved changes in custom plugin

I have a custom plugin that adds a group of pages for the user to select/unselect (similar to the multiselect field except that it supports child pages). The plugin does NOT extend any existing ones.

I’ve hit a problem that the “revert” button does not reset unsaved changes. Is there a global event I should be listening to to manually handle this? What’s the recommended approach to handling this?

Could you post the code for your plugin?

On a side note: Why don’t you use the pages field?

Could you post the code for your plugin?

index.php

<?php

// Some of this is adapted from: https://github.com/rasteiner/k3-pagesdisplay-section/blob/master/src/PagesDisplaySection.php

use Kirby\Exception\InvalidArgumentException;
use Kirby\Cms\Section;
use Kirby\Toolkit\Query;

Kirby::plugin('myNamespace/parentChildSelect', [
   'collectionFilters' => [
      'hasCategory' => function ($collection, $field, $pageSlug, $keyForLookup) {
         foreach ($collection->data as $key => $item) {
            $fieldValue = $collection->getAttribute($item, $field);

            $parsed = Json::decode($fieldValue->toString());

            if (in_array($pageSlug, $parsed[$keyForLookup])) {
               continue;
            } else {
               unset($collection->$key);
            }
         }

         return $collection;
      },
   ],
   'fields' => [
      'parentChildSelect' => [
         'props' => [
            'query' => function (string $query = null) {
               return $query;
            },
            'label' => function (string $label = null) {
               return $label;
            },
            'help' => function (string $help = null) {
               return $help;
            },
         ],
         'computed' => [
            'categoryPages' => function () {
               $kirby = kirby();
               $q = new Query($this->query, [
                  'kirby' => $kirby,
                  'site' => $kirby->site(),
                  'pages' => $kirby->site()->pages(),
                  'page' => $this->model(),
               ]);

               $pages = $q->result();

               if (!is_a($pages, \Kirby\Cms\Pages::class)) {
                  $result = $pages === null ? 'null' : get_class($pages);
                  throw new InvalidArgumentException(
                     "Query result must be of type \"Kirby\\Cms\\Pages\", \"{$result}\" given"
                  );
               }

               foreach ($pages->data as $item) {
                  $item->title = $item->title()->value();
                  $item->children = $item->children()->data();
                  foreach ($item->children as $child) {
                     $child->title = $child->title()->value();
                  }
               }

               return Json::encode($pages->data);
            },
         ],
         'save' => function ($value = null) {
            if (is_array($value) && count($value) > 0) {
               $value = Json::encode($value);
            } else {
               $value = null;
            }

            return $value;
         },
      ],
   ],
]);

index.js

window.panel.plugin('myNamespace/parentChildSelect', {
   fields: {
      parentChildSelect: {
         props: {
            categoryPages: Array,
            label: String,
            help: String,
         },
         data() {
            return {
               items: this.categoryPages && JSON.parse(this.categoryPages),
               selection: {
                  parents: [],
                  children: [],
               },
            }
         },
         mounted() {
            // If there's a draft, any existing categories will have already been parsed from JSON into a JS object.
            // Accordingly, we need to handle loading them as either.
            var maybeExistingSelection = this.$store.getters['content/values']().categories
            if (maybeExistingSelection) {
               this.selection =
                  typeof maybeExistingSelection === 'object'
                     ? maybeExistingSelection
                     : JSON.parse(maybeExistingSelection)
            }
         },
         methods: {
            select(selectionPath, value) {
               this.selection[selectionPath].push(value)
            },
            unselect(selectionPath, value) {
               this.selection[selectionPath] = this.selection[selectionPath].filter((i) => i !== value)
            },
            handleClick(selectionPath, value) {
               if (this.selection[selectionPath].includes(value)) {
                  // Unselect the clicked item
                  this.unselect(selectionPath, value)

                  // If unselecting a parent, also ensure all children are unselected.
                  if (selectionPath === 'parents') {
                     if (this.items[value].children) {
                        Object.keys(this.items[value].children).forEach((childKey) => {
                           this.unselect('children', childKey)
                        })
                     }
                  }
               } else {
                  // Select the clicked item.
                  this.select(selectionPath, value)

                  // If it's a child, ensure that the parent is selected.
                  if (selectionPath === 'children') {
                     this.select('parents', value.substring(value.lastIndexOf('/'), 0))
                  }
               }

               // This effectively tells Kirby that a change has been made that needs to be saved or discarded.
               // Without this no changes on the field are ever saved.
               this.$emit('input', this.selection)
            },
         },
         template: /* html */ `
            <div>
               <k-headline class="_mb075">
                  {{ label }}
                  <span 
                     v-if="selection.parents.length + selection.children.length"
                     class="_c-light"
                  >
                     ({{ selection.parents.length + selection.children.length }})
                  </span>
               </k-headline>
               <div class="parentChildSelect-container">
                  <div 
                     class="parentChildSelect-parentChildWrapper"
                     v-for="(parentValue, parentKey) in items"
                  >
                     <button 
                        type="button"
                        class="parentChildSelect-entry parentChildSelect-entryParent"
                        v-bind:class="{ 'parentChildSelect-entrySelected': selection.parents.includes(parentKey) }"
                        @click="handleClick('parents', parentKey)"
                     >
                        <span class="parentChildSelect-checkbox"></span>
                        {{ parentValue.title }}
                     </button>
                     <div v-if="parentValue.children">
                        <button 
                           type="button"
                           class="parentChildSelect-entry parentChildSelect-entryChild"
                           v-bind:class="{ 'parentChildSelect-entrySelected': selection.children.includes(childKey) }"
                           @click="handleClick('children', childKey)"
                           v-for="(childValue, childKey) in parentValue.children"
                        >
                           <span class="parentChildSelect-checkbox"></span>
                           {{ childValue.title }}
                        </button>
                     </div>
                  </div>
               </div>
               <div 
                  v-if="help"
                  class="_mt075 _fs-14 _c-light"
                  v-html="help"
               >
               </div>
            </div>
         `,
      },
   },
})

index.css
(Note: This is not relevant to the bug but including it here in case it’s helpful for others. There’s also some other css used that’s defined in a global css file that’s not included here.)

.parentChildSelect-container {
   background: var(--field-input-background);
   padding: 5px;
   max-height: 400px;
   overflow-y: auto;
   overflow-x: hidden;
}
.parentChildSelect-entry {
   line-height: 1;
   width: 100%;
   padding: 6px 6px;
   text-align: left;
   cursor: pointer;
   display: flex;
   color: var(--color-gray-600);
}
.parentChildSelect-entry:hover {
   background: var(--color-gray-100);
}

.parentChildSelect-entryChild {
   margin-left: 24px;
   position: relative;
}

.parentChildSelect-entrySelected {
   color: var(--color-text);
}

.parentChildSelect-entryChild:before {
   content: '∟';
   position: absolute;
   left: -14px;
   top: 0;
   color: var(--color-gray-400);
}


/* Checkbox */

.parentChildSelect-checkbox {
   width: 14px;
   height: 14px;
   border: solid 1px var(--color-gray-400);
   margin-right: 8px;
   position: relative;
   background: var(--color-gray-200);
}
.parentChildSelect-entrySelected .parentChildSelect-checkbox {
   border-color: gray;
   background: var(--color-green-600);
}
.parentChildSelect-entrySelected .parentChildSelect-checkbox:after {
   content: '✓';
   position: absolute;
   color: white;
   font-size: 14px;
   top: -1px;
}

Example of what’s saved to the content file (pretty printed here, it’s not saved as such):

Categories: {
   "parents": [
      "blog-categories/healthy-food",
      "blog-categories/healthy-lifestyle"
   ],
   "children": [
      "blog-categories/healthy-lifestyle/gardening"
   ]
}

On a side note: Why don’t you use the pages field?

There are a few things about the pages field that make it not work for our use case:

  • We need to ensure that if a child is selected the parent is also selected. AFAIK there’s no way to achieve this with the pages field.
  • Likewise, we need to sure that if a parent is unselected all of the children are also unselected. AFAIK there’s no way to achieve this with the pages field.
  • Selected child pages from the pages field are not displayed in their hierarchy (i.e. it’s not clear that their children or what’s their parent is when they’re displayed).
  • From a content editor’s perspective, being able to see all the options at one time is helpful. The UX of the pages field requires users to click a parent in order to view their children.

For context: We’re migrating a blog from WP which made extensive use of WP’s nested categories. Having a way to assign categories in a similar way was more or less a requirement.

Here’s a screenshot for reference:

Hm, I’m getting an error when trying to save the field. does this work for you and if so, with which version of Kirby are you testing?

Yes, saving the field works for me on Kirby version 3.6.5 (latest stable).

What error are you getting?

Shouldn’t there be a value: Object prop from where kirby tells you what your field contains?

Kirby uses the kinda “standard” Vue “v-model” interface. This means that it gives you the current value via a value prop, and receives updated values via the input event.
You’re loading the current value in the mounted hook, but that is run only once. You would probably want to have your selected variable to be a computed, that uses the this.value prop.
Then you woudn’t want to directly set this.selection, but emit input events so that this.selection gets computed correctly.
Don’t know if I’m explaining myself.

Something like this:

dataflow

Thanks for your insight. It helped me solve the issue.

I was not able to get an option working using computed but I was with watch.

The updated index.js:

window.panel.plugin('myNamespace/parentChildSelect', {
   fields: {
      parentChildSelect: {
         props: {
            categoryPages: Array,
            label: String,
            help: String,
            value: Object,
         },
         data() {
            return {
               items: this.categoryPages && JSON.parse(this.categoryPages),
               selection: this.blankSelection(),
            }
         },
         mounted() {
            // If there's a draft, any existing categories will have already been parsed from JSON into a JS object.
            // Accordingly, we need to handle loading them as either.
            var maybeExistingSelection = this.value
            if (maybeExistingSelection) {
               this.selection =
                  typeof maybeExistingSelection === 'object'
                     ? maybeExistingSelection
                     : JSON.parse(maybeExistingSelection)
            }
         },
         methods: {
            blankSelection() {
               return {
                  parents: [],
                  children: [],
               }
            },
            select(selectionPath, value) {
               this.selection[selectionPath].push(value)
            },
            unselect(selectionPath, value) {
               this.selection[selectionPath] = this.selection[selectionPath].filter((i) => i !== value)
            },
            handleClick(selectionPath, value) {
               if (this.selection[selectionPath].includes(value)) {
                  // Unselect the clicked item
                  this.unselect(selectionPath, value)

                  // If unselecting a parent, also ensure all children are unselected.
                  if (selectionPath === 'parents') {
                     if (this.items[value].children) {
                        Object.keys(this.items[value].children).forEach((childKey) => {
                           this.unselect('children', childKey)
                        })
                     }
                  }
               } else {
                  // Select the clicked item.
                  this.select(selectionPath, value)

                  // If it's a child, ensure that the parent is selected.
                  if (selectionPath === 'children') {
                     var parent = value.substring(value.lastIndexOf('/'), 0)
                     if (!this.selection.parents.includes(parent)) {
                        this.select('parents', parent)
                     }
                  }
               }

               // This effectively tells Kirby that a change has been made that needs to be saved or discarded.
               // Without this no changes on the field are ever saved.
               this.$emit('input', this.selection)
            },
         },
         watch: {
            value(newValue) {
               if (newValue) {
                  this.selection = typeof newValue === 'object' ? newValue : JSON.parse(newValue)
               } else {
                  this.selection = this.blankSelection()
               }
            },
         },
         template: /* html */ `
            <div>
               <k-headline class="_mb075">
                  {{ label }}
                  <span 
                     v-if="selection.parents.length + selection.children.length"
                     class="_c-light"
                  >
                     ({{ selection.parents.length + selection.children.length }})
                  </span>
               </k-headline>
               <div class="parentChildSelect-container">
                  <div 
                     class="parentChildSelect-parentChildWrapper"
                     v-for="(parentValue, parentKey) in items"
                  >
                     <button 
                        type="button"
                        class="parentChildSelect-entry parentChildSelect-entryParent"
                        v-bind:class="{ 'parentChildSelect-entrySelected': selection.parents.includes(parentKey) }"
                        @click="handleClick('parents', parentKey)"
                     >
                        <span class="parentChildSelect-checkbox"></span>
                        {{ parentValue.title }}
                     </button>
                     <div v-if="parentValue.children">
                        <button 
                           type="button"
                           class="parentChildSelect-entry parentChildSelect-entryChild"
                           v-bind:class="{ 'parentChildSelect-entrySelected': selection.children.includes(childKey) }"
                           @click="handleClick('children', childKey)"
                           v-for="(childValue, childKey) in parentValue.children"
                        >
                           <span class="parentChildSelect-checkbox"></span>
                           {{ childValue.title }}
                        </button>
                     </div>
                  </div>
               </div>
               <div 
                  v-if="help"
                  class="_mt075 _fs-14 _c-light"
                  v-html="help"
               >
               </div>
            </div>
         `,
      },
   },
})