Very new to Piranha and Vue, but not to .Net Core. Trying to get my arms around how to create custom blocks. I’ve created a new block, attempting to marry the HtmlBlock and ImageBlock:

using Piranha.Extend;
using Piranha.Extend.Blocks;
using Piranha.Extend.Fields;

namespace YR.Models.Piranha.Blocks
{
    [BlockType(Name = "Card", Category = "Content", Icon = "fas fa-address-card", Component = "card-block")]
    public class CardBlock : Block
    {
        public ImageField ImgBody { get; set; }
        public SelectField<ImageAspect> Aspect { get; set; } = new SelectField<ImageAspect>();
        public HtmlField HtmlBody { get; set; }

        public override string GetTitle()
        {
            if (ImgBody != null && ImgBody.Media != null)
            {
                return ImgBody.Media.Filename;
            }
            return "No image selected";
        }
    }
}

If I omit the Component property in the BlockTypeAttribute, the block works, saves the image and content to draft and updates the site perfectly when published. For the full experience in the manager, I tried to also build a Vue component that just combined both the html-block.vue and image-block.vue components.

Here’s what I have for the Vue component:

Vue.component("card-block", {
    props: ["uid", "toolbar", "model"],
    data: function () {
        return {
            imgBody: this.model.imgBody.value,
            htmlBody: this.model.htmlBody.value
        };
    },
    methods: {
        clear: function () {
            // clear media from block
        },
        onBlur: function (e) {
            this.model.htmlBody.value = e.target.innerHTML;
        },
        onChange: function (data) {
            this.model.htmlBody.value = data;
        },
        remove: function () {
            this.model.imgBody.id = null;
            this.model.imgBody.media = null;
        },
        select: function () {
            if (this.model.imgBody.media != null) {
                piranha.mediapicker.open(this.update, "Image", this.model.imgBody.media.folderId);
            } else {
                piranha.mediapicker.openCurrentFolder(this.update, "Image");
            }
        },
        update: function (media) {
            if (media.type === "Image") {
                this.model.imgBody.id = media.id;
                this.model.imgBody.media = media;
                
                // Tell parent that title has been updated
                this.$emit('update-title', {
                    uid: this.uid,
                    title: this.model.imgBody.media.filename
                });
            } else {
                console.log("No image was selected");
            }
        }
    },
    computed: {
        isEmpty: function () {
            return {
                htmlBody: piranha.utils.isEmptyHtml(this.model.htmlBody.value),
                imgBody: this.model.imgBody.media == null
            }
        },
        mediaUrl: function () {
            if (this.model.imgBody.media != null) {
                return piranha.utils.formatUrl(this.model.imgBody.media.publicUrl);
            } else {
                return piranha.utils.formatUrl("~/manager/assets/img/empty-image.png");
            }
        }
    },
    mounted: function () {
        piranha.editor.addInline(this.uid, this.toolbar, this.onChange);
        this.model.imgBody.getTitle = function () {
            if (this.model.imgBody.media != null) {
                return this.model.imgBody.media.filename;
            } else {
                return "No image selected";
            }
        };
    },
    beforeDestroy: function () {
        piranha.editor.remove(this.uid);
    },
    template:
        "<div class='block-body has-media-picker rounded' :class='{ empty: isEmpty }'>" + 
        "   <img class='rounded' :src='mediaUrl'>" +
        "   <div class='media-picker'>" +
        "    <div class='btn-group float-right'>" +
        "        <button :id='uid' class='btn btn-info btn-aspect text-center' data-toggle='dropdown' aria-haspopup='true' aria-expanded='false'>" +
        "            <i v-if='model.aspect.value === 0' class='fas fa-cog'></i>" +
        "            <img v-else :src='iconUrl'>" +
        "        </button>" +
        "        <div class='dropdown-menu aspect-menu' :aria-labelledby='uid'>" +
        "            <label class='mb-0'>{{ piranha.resources.texts.aspectLabel }}</label>" +
        "            <div class='dropdown-divider'></div>" +
        "            <a v-on:click.prevent='selectAspect(0)' class='dropdown-item' :class='{ active: isAspectSelected(0) }' href='#'>" +
        "                <img :src='piranha.utils.formatUrl('~/manager/assets/img/icons/img-original.svg')'><span>{{ piranha.resources.texts.aspectOriginal }}</span>" +
        "            </a>" +
        "           <a v-on:click.prevent='selectAspect(1)' class='dropdown-item' :class='{ active: isAspectSelected(1) }' href='#'>" +
        "                <img :src='piranha.utils.formatUrl('~/manager/assets/img/icons/img-original.svg')'><span>{{ piranha.resources.texts.aspectOriginal }}</span>" +
        "            </a>" + 
        "           <a v-on:click.prevent='selectAspect(2)' class='dropdown-item' :class='{ active: isAspectSelected(2) }' href='#'>" +
        "                <img :src='piranha.utils.formatUrl('~/manager/assets/img/icons/img-original.svg')'><span>{{ piranha.resources.texts.aspectOriginal }}</span>" +
        "            </a>" + 
        "           <a v-on:click.prevent='selectAspect(3)' class='dropdown-item' :class='{ active: isAspectSelected(3) }' href='#'>" +
        "                <img :src='piranha.utils.formatUrl('~/manager/assets/img/icons/img-original.svg')'><span>{{ piranha.resources.texts.aspectOriginal }}</span>" +
        "            </a>" + 
        "           <a v-on:click.prevent='selectAspect(4)' class='dropdown-item' :class='{ active: isAspectSelected(4) }' href='#'>" +
        "                <img :src='piranha.utils.formatUrl('~/manager/assets/img/icons/img-original.svg')'><span>{{ piranha.resources.texts.aspectOriginal }}</span>" +
        "            </a>" + 
        "        </div>" +
        "        <button v-on:click.prevent='select' class='btn btn-primary text-center'>" +
        "            <i class='fas fa-plus'></i>" +
        "        </button>" +
        "        <button v-on:click.prevent='remove' class='btn btn-danger text-center'>" +
        "            <i class='fas fa-times'></i>" +
        "         </button>" +
        "    </div>" +
        "    <div class='card text-left'>" +
        "        <div class='card-body' v-if='isEmpty'>" +
        "             " +
        "        </div>" +
        "        <div class='card-body' v-else>" +
        "            {{ model.body.media.filename }}" +
        "        </div>" +
        "    </div>" +
        "</div>" +
        "  <div contenteditable='true' :id='uid' spellcheck='false' v-html='htmlBody' v-on:blur='onBlur'></div>" +
        "</div>" 
});

It’s basically a meld of the two Vue components that I found in the repo on GitHub, but I’ve massaged it a little to get it to not spit out errors in the DevTools console. I’ll revisit those items if I can just get past saving it.

So, here are my questions for @tidyui or anyone who’s successfully implemented something like this:

  1. Am I going about this the right way? I simply want three columns, each column would contain my CardBlock which has a picture and a blurb of content under it, but I want the CardBlock to be a single unit (kind of like a Bootstrap Card). Is there already a way to do this without creating my own block? I researched nesting BlockGroups, but quickly found out that it isn’t possible.

  2. If I’m on the right track, I need help with the error I’m getting when I attempt to save the draft. The error is identical to Save ImageBlock error #1117, which appears to have been fixed in 8.2. I’m on 8.4.2.

I absolutely LOVE the idea of a CMS built for .Net Core, and Piranha blows away the CMS I created for myself. I just need a little push in the right direction, please, I’ve been at this for most of the day.

Thanks in advance,
D

1

Just in case this helps someone. Turns out I did a poor job merging the two blocks together. Chalk this one up to inexperience with both Piranha and Vue.js. I was mixing the code from the docs with the code in the repo. Don’t do that – the docs are understandably still a bit behind. I’m not throwing stones at the developers, I really dig what they’ve created and will continue to put forth the effort to use it proficiently.

Below is what I came up with for the Vue component. There are probably still a few tweaks to be made to separate the Image-Block and Html-Block code better, but it now works, saves, and does not throw errors in the console.

/*global
    piranha
*/

Vue.component("card-block", {
    props: ["uid", "toolbar", "model"],
    data: function () {
        return {
            imgBody: this.model.imgBody.value,
            htmlBody: this.model.htmlBody.value
        };
    },
    methods: {
        clear: function () {
            // clear media from block
        },
        onBlur: function (e) {
            this.model.htmlBody.value = e.target.innerHTML;
        },
        onChange: function (data) {
            this.model.htmlBody.value = data;
        },
        select: function () {
            if (this.model.imgBody.media != null) {
                piranha.mediapicker.open(this.update, "Image", this.model.imgBody.media.folderId);
            } else {
                piranha.mediapicker.openCurrentFolder(this.update, "Image");
            }
        },
        remove: function () {
            this.model.imgBody.id = null;
            this.model.imgBody.media = null;
        },
        update: function (media) {
            if (media.type === "Image") {
                this.model.imgBody.id = media.id;
                this.model.imgBody.media = {
                    id: media.id,
                    folderId: media.folderId,
                    type: media.type,
                    filename: media.filename,
                    contentType: media.contentType,
                    publicUrl: media.publicUrl,
                };
                // Tell parent that title has been updated
                this.$emit('update-title', {
                    uid: this.uid,
                    title: this.model.imgBody.media.filename
                });
            } else {
                console.log("No image was selected");
            }
        },
        selectAspect: function (val) {
            this.model.aspect.value = val;
        },
        isAspectSelected(val) {
            return this.model.aspect.value === val;
        }
    },
    computed: {
        isImgEmpty: function (e) {
            return this.model.imgBody.media == null;
        },
        isHtmlEmpty: function () {
            return piranha.utils.isEmptyHtml(this.model.htmlBody.value);
        },
        mediaUrl: function () {
            if (this.model.imgBody.media != null) {
                return piranha.utils.formatUrl(this.model.imgBody.media.publicUrl);
            } else {
                return piranha.utils.formatUrl("~/manager/assets/img/empty-image.png");
            }
        },
        iconUrl: function () {
            if (this.model.aspect.value > 0) {
                if (this.model.aspect.value === 1 || this.model.aspect.value === 3) {
                    return piranha.utils.formatUrl("~/manager/assets/img/icons/img-landscape.svg");
                } else if (this.model.aspect.value == 2) {
                    return piranha.utils.formatUrl("~/manager/assets/img/icons/img-portrait.svg");
                } else if (this.model.aspect.value == 4) {
                    return piranha.utils.formatUrl("~/manager/assets/img/icons/img-square.svg");
                }
            }
            return null;
        }
    },
    mounted: function () {
        piranha.editor.addInline(this.uid, this.toolbar, this.onChange);
        this.model.getTitle = function () {
            if (this.model.imgBody.media != null) {
                return this.model.imgBody.media.filename;
            } else {
                return "No image selected";
            }
        };
    },
    beforeDestroy: function () {
        piranha.editor.remove(this.uid);
    },
    template:
       
        "<div class='block-body has-media-picker rounded' :class='{ empty: isImgEmpty }'>" + 
        "   <div class='image-block'>" +
        "       <img class='rounded' :src='mediaUrl'>" +
        "       <div class='media-picker'>" +
        "           <div class='btn-group float-right'>" +
        "               <button :id='uid + "-aspect"' class='btn btn-info btn-aspect text-center' data-toggle='dropdown' aria-haspopup='true' aria-expanded='false'>" +
        "                <i v-if='model.aspect.value === 0' class='fas fa-cog'></i>" +
        "                   <img v-else :src='iconUrl'>" +
        "               </button>" +
        "               <div class='dropdown-menu aspect-menu' :aria-labelledby='uid + "-aspect"'>" +
        "                   <label class='mb-0'>{{ piranha.resources.texts.aspectLabel }}</label>" +
        "                   <div class='dropdown-divider'></div>" +
        "                   <a v-on:click.prevent='selectAspect(0)' class='dropdown-item' :class='{ active: isAspectSelected(0) }' href='#'>" +
        "                       <img :src='piranha.utils.formatUrl("~/manager/assets/img/icons/img-original.svg")'><span>{{ piranha.resources.texts.aspectOriginal }}</span>" +
        "                   </a>" +
        "                   <a v-on:click.prevent='selectAspect(1)' class='dropdown-item' :class='{ active: isAspectSelected(1) }' href='#'>" + 
        "                       <img :src='piranha.utils.formatUrl("~/manager/assets/img/icons/img-landscape.svg")'><span>{{ piranha.resources.texts.aspectLandscape }}</span>" +
        "                   </a>" +
        "                   <a v-on:click.prevent='selectAspect(2)' class='dropdown-item' :class='{ active: isAspectSelected(2) }' href='#'>" +
        "                       <img :src='piranha.utils.formatUrl("~/manager/assets/img/icons/img-portrait.svg")'><span>{{ piranha.resources.texts.aspectPortrait }}</span>" +
        "                   </a>" + 
        "                   <a v-on:click.prevent='selectAspect(3)' class='dropdown-item' :class='{ active: isAspectSelected(3) }' href='#'>" +
        "                       <img :src='piranha.utils.formatUrl("~/manager/assets/img/icons/img-landscape.svg")'><span>{{ piranha.resources.texts.aspectWidescreen }}</span>" +
        "                   </a>" + 
        "                   <a v-on:click.prevent='selectAspect(4)' class='dropdown-item' :class='{ active: isAspectSelected(4) }' href='#'>" +
        "                       <img :src='piranha.utils.formatUrl("~/manager/assets/img/icons/img-square.svg")'><span>{{ piranha.resources.texts.aspectSquare }}</span>" +
        "                   </a>" + 
        "               </div>" +
        "               <button v-on:click.prevent='select' class='btn btn-primary text-center'>" +
        "                   <i class='fas fa-plus'></i>" +
        "               </button>" +
        "               <button v-on:click.prevent='remove' class='btn btn-danger text-center'>" +
        "                   <i class='fas fa-times'></i>" +
        "               </button>" +
        "           </div>" +
        "           <div class='card text-left'>" +
        "               <div class='card-body' v-if='isImgEmpty'>" +
        "                    " +
        "               </div>" +
        "               <div class='card-body' v-else>" +
        "                   {{ model.imgBody.media.filename }}" +
        "               </div>" +
        "           </div>" +
        "       </div>" +
        "   </div>" +
        "   <br />" +
        "   <div class='html-block'>" +
        "       <div class='block-body border rounded' :class='{ empty: isHtmlEmpty }' >" + 
        "           <div contenteditable='true' :id='uid' v-html='htmlBody' v-on:blur='onBlur'></div> " + 
        "       </div>" +
        "   </div>" +
        "</div>"
});

I’d still love to get some confirmation that I didn’t do all of this unnecessarily. If there’s a better way to have done it, please don’t hold back.