7. Stretch goal: folder contents row actions#

This chapter is for fast participants, and it is a real customer request. The folder contents view, the pat-structure pattern, shows an action menu in every row: Open, Edit, and a dropdown with Cut, Copy, Paste, and more. The customer wants two changes:

  • Edit opens in a modal, instead of leaving the folder contents.

  • Images get an additional action that opens the image cropping editor of plone.app.imagecropping, also in a modal.

It combines Replacing a core pattern with a look under the hood of a Backbone-based pattern.

7.1. Why the menuOptions option is not enough#

The pattern has an option menuOptions, and it looks like the answer. It is not. Mockup builds the menu per row in src/pat/structure/js/actionmenu.js:

const ActionMenu = function (menu) {
    // If an explicit menu was specified as an option to AppView, this
    // constructor will not override that.
    if (menu.app.menuOptions !== null) {
        return menu.app.menuOptions;
    }
    const model = menu.model.attributes;
    ...
    result.openItem.url = model.getURL + viewAction;
    result.editItem.url = model.getURL + "/edit";
    return result;
};

With menuOptions set, the generator returns your static definition for every row. The per-row logic is skipped: the Open and Edit links get no URL, and Paste, Move, and “Set as default page” no longer depend on the item. You could remove actions that way, but you can’t add a per-item link.

7.2. The recipe#

The menu is generated in the initialize method of the ActionMenuView. That is where we hook in. Module federation shares only a few core modules between the Plone bundle and our add-on, so our add-on ships its own copy of the structure app. We can patch the ActionMenuView of that copy, but only if our copy of the pattern is the one that runs. This is the blacklist recipe from Replacing a core pattern.

Add structure to static/pattern-blacklist.js:

window.__patternslib_patterns_blacklist = (
    window.__patternslib_patterns_blacklist || []
).concat(["markspeciallinks", "structure"]);

Create resources/structure/structure.js:

import $ from "jquery";
import mockupParser from "@patternslib/patternslib/src/core/mockup-parser";
import Structure from "@plone/mockup/src/pat/structure/structure";
import ActionMenuView from "@plone/mockup/src/pat/structure/js/views/actionmenu";
import utils from "@plone/mockup/src/core/utils";

// Mockup resolves menu icons while rendering the row, without awaiting the
// first fetch. Warm the icon cache for our new icon, so that the first row
// already shows it instead of the title text.
utils.resolveIcon("crop");

const original_initialize = ActionMenuView.prototype.initialize;
ActionMenuView.prototype.initialize = function (options) {
    original_initialize.call(this, options);

    // this.menuOptions is the generated menu for THIS row: Paste, Move and
    // "Set as default page" are already filtered, the URLs are resolved.
    const item = this.model.attributes;

    // 1. Open the edit form in a modal.
    this.menuOptions.editItem.css = "pat-plone-modal";

    // 2. Add the cropping editor for images, also in a modal.
    if (item.portal_type === "Image") {
        this.menuOptions.cropItem = {
            url: `${item.getURL}/@@croppingeditor`,
            title: "Crop image",
            category: "button",
            icon: "crop",
            css: "pat-plone-modal",
            modal: false,
        };
    }

    // Re-bind the click handlers, in case you add or remove entries with a
    // ``method``. Methods must exist in src/pat/structure/js/actions.js.
    this.events = this.generate_events();
    this.delegateEvents();
};

export default Structure.extend({
    name: "blicca-structure",
    trigger: ".pat-structure",
    parser: null,

    async init() {
        // Take over the options of the original, ``data-pat-structure``.
        this.options = $.extend(
            true,
            {},
            this.defaults,
            mockupParser.getOptions(this.el, "structure"),
        );
        return this.constructor.__super__.init.call(this);
    },
});

Import it in resources/overrides.js, next to the markspeciallinks replacement.

Each menu entry has the same shape: url, title, category, icon, css, and modal. The category button renders the entry next to Open and Edit, dropdown puts it into the gear menu. The css classes end up on the link, so pat-plone-modal opens it in a modal. Entries with a method call a method of src/pat/structure/js/actions.js, such as cutClicked or moveTopClicked.

Note

The modal: true flag looks like the official way, but it has no effect in Mockup 5.6. The view appends the modal class after it has built the class list of the entry. Set the css class yourself, as the comment in the view suggests.

7.3. The pnpm caveat#

The structure app imports pat-select2, and with it the patched select2 fork that Mockup installs from git. Our pnpm-workspace.yaml removes that fork on purpose, see Setup. For this stretch goal, allow it:

  • Remove the "select2": "-" line from overrides.

  • Set "@plone/mockup": true in allowBuilds.

  • Add blockExoticSubdeps: false.

Then run pnpm install and pnpm run build again. Mockup’s postinstall script tries to patch select2, and doesn’t find it in the pnpm store. The unpatched fork only differs in how already selected items are highlighted in the related items widget, which the folder contents don’t use.

7.4. A lighter alternative: a pattern on the action menu#

The prototype patch changes the generated menu itself. If all you need is to decorate the rendered menu, add a class here, add a link there, a small pattern as in Your own pattern does the job. No blacklist, no copy of the structure app, no change to the pnpm configuration.

The hook is at the end of the render() method of the ActionMenuView, in src/pat/structure/js/views/actionmenu.js:

registry.scan(this.$el);
return this.el;

Mockup scans every rendered row menu with the registry, so that the tooltips and modals on the buttons initialize. The registry is shared with our add-on, so a pattern of ours with a matching trigger runs for every row, and again whenever the rows re-render on paging, sorting, or a folder change.

Two details of the row view, src/pat/structure/js/views/tablerow.js, matter:

this.el.model = this.model;
...
const menuview = new ActionMenuView({ app: this.app, model: this.model });
$(".actionmenu-container", this.$el).append(await menuview.render());

The row keeps the Backbone model of the item on its DOM element, so the pattern can read portal_type and getURL from there. And the menu is scanned before it is appended to the row. At that moment the menu has no ancestors: a trigger like .pat-structure .actionmenu would not match, and closest("tr") would find nothing.

Create resources/folder-contents-actions/actions.js:

import { BasePattern } from "@patternslib/patternslib/src/core/basepattern";
import registry from "@patternslib/patternslib/src/core/registry";
import utils from "@plone/mockup/src/core/utils";

class Pattern extends BasePattern {
    static name = "blicca-folder-contents-actions";
    // The menu is scanned while it is still detached from the table, so the
    // trigger must match the menu element itself, not a descendant of
    // ".pat-structure".
    static trigger = ".btn-group.actionmenu";

    async init() {
        // Wait a tick, until the menu is appended to its row.
        await new Promise((resolve) => setTimeout(resolve));
        const row = this.el.closest(".pat-structure tr");
        // pat-structure stores the Backbone model of the item on its row.
        const item = row?.model?.attributes;
        if (!item) {
            return;
        }

        // 1. Open the edit form in a modal.
        const edit = this.el.querySelector("a.editItem");
        if (edit) {
            edit.classList.add("pat-plone-modal");
            registry.scan(edit);
        }

        // 2. Add the cropping editor for images, also in a modal.
        if (item.portal_type === "Image" && edit) {
            const crop = document.createElement("a");
            crop.className = "btn btn-sm action cropItem pat-plone-modal";
            crop.href = `${item.getURL}/@@croppingeditor`;
            crop.title = "Crop image";
            crop.setAttribute("aria-label", "Crop image");
            crop.innerHTML = await utils.resolveIcon("crop");
            edit.after(crop);
            registry.scan(crop);
        }
    }
}

registry.register(Pattern);
export default Pattern;

Import it in resources/overrides.js instead of the structure replacement, and rebuild. registry.scan(link) initializes the modal pattern on the changed link, and utils.resolveIcon() fetches an icon from Plone’s icon resolver by its registered name.

Use one variant or the other, not both: with both active, the image row gets two crop buttons. The solution branches are stretch-folder-contents for the prototype patch, and stretch-folder-contents-pattern for this variant.

7.5. Checkpoint#

Open the folder contents of a folder with an image. Edit opens in a modal for every item, and the image row has a crop button that opens the cropping editor in a modal. Change into a subfolder and back: the rows re-render, and your changes are there again. With the prototype patch, also cut an item with the gear menu: the folder rows now offer Paste, so the re-bound click handlers work.

Note

The cropping action needs plone.app.imagecropping installed in your project. Without it, the link returns a 404 error.

Warning

The structure app pulls a lot of code into your bundle, around 90 KB for the structure chunk alone, plus its dependencies. Keep such a customization in the customer’s add-on, and check the bundle size before you ship it.