Building a text flow editor for the grid

Books I read
See all recommendations

I'm continuing my journey in search of a good separation between content and layout in Umbraco. I'm fairly confident it should be quite quick to get a prototype together. Even when blogging while doing it. Let's see if we can't create a grid editor which can be configured to show another rich text editor property in the document. It should also have a way to limit said content. Say n root elements from the HTML.

In our sites, we've modified the grid slightly. There's a PR hoping to be pulled to the core. (Now also a package) It makes it possible to create a "template" for new grids of a type. Whenever the user creates a "Page", it has a grid with a header, a summary and a body. It's also got a column for widgets and hullabaloo's. The template is just the grid on the data type settings page. It's just another setting. If I put that on tab number 2, then in many cases, editors wouldn't even have to relate to the layout. They'll tackle that when they do need to add a form or other functionality.

So with this new editor, I'm hoping to swap out the header and the rich text editors with pointers to some structured fields instead. As an added challenge, my eminent colleague and world class UX expert Stig Hammer insists this will only be used by 5% of all editors. It'll be too abstract and complex for the general crowd to use. We'll see. We might just be surprised.

Follow along?

The code for this article is on GitHub. There's a chance I'll miss an update or two in the samples here, but there's working code in the repo. I'll also omit large setup fragments and other things that obscure the valuable bits.

First iteration: UI prototype

I'd like to start off by having a placeholder in the grid. It should make use of grid editor settings (a cog for grid editors) and two settings where we'll use some one-off "virtual" property editors. We'll stick with the "nerdy" UX for now and see about some sugar later.

Except for the views, it should pretty much just be package.manifest configuration. Here's what I'm starting out with:

{
    "gridEditors": [
        {
            "name": "Flowing text",
            "alias": "flowingText",
            "view": "/App_Plugins/FlowingText/editor.html",
            "icon": "icon-article",
            "config": {
                "settings": {
                    "source-property": {
                        "label": "Source property",
                        "key": "source-property",
                        "description": "The source for the text",
                        "view": "/umbraco/views/propertyeditors/dropdown/dropdown.html",
                        "config": {
                            "items": {
                                "dummy": "Dummy",
                                "dummy2": "Dummy 2"
                            }
                        }
                    },
                    "fragments": {
                        "label": "Fragments to show",
                        "key": "fragments",
                        "description": "How many paragraphs or fragments to show",
                        "view": "/umbraco/views/propertyeditors/integer/integer.html",
                        "config": {

                        }
                    }
                }
            }
        }
    ]
}

We'll just stub up a simple view for now to see if the settings dialog works as expected.

<div>Some day I'll flow like a river!</div>

I've changed the Fanoe Text Page to have an RTE called "Body Text" on a tab before the one with the "Content" grid on it. I've changed the tab names so the body is in "Content" and the grid is in "Layout".

Jumping into the layout and the grid, I've enabled "Flowing text" for a couple of the row defs. Adding one and clicking the cog gives me this:

Grid editor with dummy template and settings based on virtual property editors

I'd say that's a feasible prototype to start with, so let's see if we can feed the properties of the document into that dropdown and show some text.

Second Iteration: Live data

Since the "property editors" we used for the settings are just pointing to the views and not the property editor defintions themselves, we're free to configure them as we want. Fortune wants it so that the entire graph from the package.manifest makes its way to the controllers we add to our grid editors. Umbraco also has this nice little service called editorState. It holds the current entity of the main editor area. In our case - the document.

I'll do this test-driven for kicks. Here's the stub of a unit test that verifies our code will populate the dropdown with the properties in the document. I've omitted some setup stuff, but you can see the entire test in the GitHub repo.

/// <reference path="/Umbraco/lib/angular/1.1.5/angular.js" />
/// <reference path="/Umbraco/lib/angular/1.1.5/angular-mocks.js" />
/// <reference path="../flowingtext.js" />
(function () {

    describe("Flowing text grid editor", function () {

        var rootScope,
            scope,
            controllerFactory,
            defaultEditorState = { ... },
            model = { ... };

        beforeEach(module("umbraco"));
        beforeEach(
            inject(function ($controller, $rootScope) {
                controllerFactory = $controller;
                rootScope = $rootScope;
                scope = rootScope.$new();
                scope.control = angular.extend({}, model);
            })
        );

        it("populates the source property options with the properties of the document", function () {

            var sourcePropertySetting = scope.control.editor.config.settings["source-property"];

            controllerFactory("our.umbraco.flowingtext.controller", {
                "$scope": scope,
                "editorState": angular.extend({}, defaultEditorState)
            }),

            expect(sourcePropertySetting.config.items).toEqual({
                "bodyText": "Body text",
                "content": "Content"
            });

        });

    });

}());

I want to verify that "content" isn't in that list, but I'll leave that for a later iteration for now. If you can't guess why i'd like that, take 2 minutes to think about that right now. ;)

In any case, let's add the controller and hook it up to our view and see where that takes us:

(function () {
    var umbraco;

    function FlowingTextController(scope, editorState) {
        var model = scope.control,
            ti, pi, 
            tab,
            prop,
            items = {},
            sourcePropertySetting = model.editor.config.settings["source-property"];

        sourcePropertySetting.config = angular.extend(
            sourcePropertySetting.config,
            { items: items }
        );

        for (ti = 0; ti < editorState.current.tabs.length; ti++) {
            tab = editorState.current.tabs[ti];
            for (pi = 0; pi < tab.properties.length; pi++) {
                prop = tab.properties[pi];
                items[prop.alias] = prop.label;
            }
        }
    }

    function initializeUmbraco() {} // Fancy testing requirements inside

    initializeUmbraco();
    umbraco.controller("our.umbraco.flowingtext.controller", [
        "$scope",
        "editorState",
        FlowingTextController
    ]);
}());

And the view:

<div ng-controller="our.umbraco.flowingtext.controller">
    Some day I'll flow like a river, really!
</div>

And don't forget the package.manifest like I did. :)

...
"javascript": [
    "~/App_Plugins/FlowingText/flowingtext.js"
],
"gridEditors": [
...

Now how does it look when we pop the settings for the editor? On second thought, let's skip the click-the-cog step for new editors. Should be a quick fix....

We'll add an empty function to the test variable list:

editGridItemSettings = function() {};

And we'll add it to the root scope in the beforeEach(inject()) call:

rootScope.editGridItemSettings = editGridItemSettings;

Add a tad to the controller

if (!model.config) {
    model.config = {};
    scope.editGridItemSettings(model, 'control');
}

Et voilĂ , when we now add a "Flowing text" to the grid, it has all the properties we could want in it's list. And the settings dialog show by default.

Grid editor with dummy template and settings based on virtual property editors

We should probably try to remove a few irrelevant properties. Not to mention the "Content" one. (What? You didn't realise why yet? Go think about it some more! Think Inception...)

I'm happy for now though. Let's see if we can't make it so that when we select "Body text" the entire contents of the rich text editor is shown in our preview.

Third iteration: Present the text

First off, until now I've tried hard to keep this as compatible with property editors as I could. I'll get sloppy now, but I promise I'll return with a post about that, or just fix this up. The code should be pretty similar, if not the same for Archetype or Nested Content versions. So anyway, here's me getting sloppy. I'm sorry.

We'll need to watch the editor's config. When we submit the settings dialog, the root scope.control.config will be given key/value pairs from the settings dialog. For a property-editor in NC or Archetype, you'd have to watch scope.model.config. So you see where the sloppyness takes place.

I've also cached up the properties in the loop where we populate the dropdown:

for (pi = 0; pi < tab.properties.length; pi++) {
    prop = tab.properties[pi];
    properties.push(prop); // Remember to declare it...
    items[prop.alias] = prop.label;
}

Let's not get ahead of ourselves, though. We add a value to the defaultEditorState's bodyText property, then we go ahead and assert it's set as model.value when we digest:

    it("sets the value when a source property is selected", function() {
        var expectedText = "<p>The expected value</p>",
            initialText = "<p>Unexpected value</p>"
            property = defaultEditorState.current.tabs[0].properties[0];

        property.value = initialText;

        scope.control.config = {
            "source-property": "bodyText"
        };
        scope.$digest();

        // we have to do double digest here to simulate update to the text
        property.value = expectedText;
        scope.$digest();

        expect(scope.control.value).toBe(expectedText);

    });

So as we expect, whenever control.config["source-property"] changes, the editor value changes to the value of that property.

Here's the satisfying code:

        scope.$watch("control.config['source-property']", function() {
            if (model.config["source-property"]) {
                sourceProperty = $.grep(properties, function(p) {
                    return p.alias === model.config["source-property"];
                })[0];

                if (watch) {
                    watch(); // unregister if source changes
                }

                watch = scope.$watch(function() {
                    return sourceProperty.value;
                }, function() {
                    model.value = sourceProperty.value;
                });
            }
        });

And the view can now show the value:

<div ng-controller="our.umbraco.flowingtext.controller"
     ng-bind-html-unsafe="control.value">
</div>

Editor showing body text and debug json

Those of you still awake will notice a little gray json snippet there. I got desperate for a little while, so I added the universal debugging tool for any angular developer:

<div ng-controller="our.umbraco.flowingtext.controller" 
     ng-bind-html-unsafe="control.value">
</div>
<pre>{{control | json}}</pre>

We're more or less done with a nice little mirroring grid editor now. It can actually already replace my header grid editor with something mirroring a single document property for the header. We just need a razor view. I know you guys know how to fix that, so let's see if we can't make it do what we set out to now.

Forth iteration: Bringing it all together

We're almost there now, and it's getting late. Let's just jump straight at the next test:

    it("gets a limited amount of fragments", function () {
        var expectedText =
                "<h1>Expected header</h1>" +
                "<p>The expected value</p>",
            allText =
                expectedText +
                "<p>Unexpected value</p>",
            property = defaultEditorState.current.tabs[0].properties[0];

        property.value = allText;

        scope.control.config = {
            "source-property": "bodyText",
            "fragments": 2
        };
        scope.$digest();

        expect(scope.control.value).toBe(expectedText);
    });

I've added some more fragments to the other property. We configure it to show only two fragments. So we expect a heading and a paragraph. Here's a quite changed controller. I'll excuse myself and claim I'll come back and refactor it. Hope you can make sense of it.

    function FlowingTextController(scope, editorState) {
        var model = scope.control,
            ti, pi, 
            tab,
            prop,
            items = {},
            properties = [],
            watch,
            sourceProperty,
            sourcePropertySetting = model.editor.config.settings["source-property"];

        // We now update the value from two watches, so the function is extracted
        function updateFlow() {
            var elements,
                begin = 0,
                end = begin + model.config.fragments;
            if (!sourceProperty) {
                return;
            }
            elements = $.grep($(sourceProperty.value), function(e) {
                return !(e instanceof Text);
            });
            model.value =
                $("<div>").append(
                    $(Array.prototype.slice.call(elements, begin, end))
                )
                .html();
        }

        sourcePropertySetting.config = angular.extend(
            sourcePropertySetting.config,
            { items: items }
        );

        for (ti = 0; ti < editorState.current.tabs.length; ti++) {
            tab = editorState.current.tabs[ti];
            for (pi = 0; pi < tab.properties.length; pi++) {
                prop = tab.properties[pi];
                properties.push(prop);
                items[prop.alias] = prop.label;
            }
        }

        // Initialize an empty config
        if (!model.config) {
            model.config = {};
            scope.editGridItemSettings(model, 'control');
        }

        // Watch the fragments too
        scope.$watch("control.config['fragments']", updateFlow);

        scope.$watch("control.config['source-property']", function() {
            if (model.config["source-property"]) {
                sourceProperty = $.grep(properties, function(p) {
                    return p.alias === model.config["source-property"];
                })[0];

                if (watch) {
                    watch();
                }

                watch = scope.$watch(function() {
                    return sourceProperty.value;
                }, updateFlow);
            }
        });
    }

There are some polish needed here to adher to all practices. But again, I'll excuse myself. It's almost 12 am. :) Running this code now let's us add the first few fragments of the body text over and over again in the grid. I've put some text from slipsum.com in the body text and had a go with it (header's mine for motivation though):

Editor with first fragments in action

I can almost feel it now. Let's do one more iteration and see if we can't get that second editor to show the rest of the body text instead of the same first parts.

Fifth iteration: Making it flow

We'll jump right into the next test again:

    it("gets the fragments remaining from the previous ones", function() {
        var expectedText =
                "<p>Expected paragraph</p>" +
                "<p>Another expected paragraph</p>",
            allText =
                "<h1>Unexpected header</h1>" +
                expectedText +
                "<p>Unexpected value</p>",
            property = defaultEditorState.current.tabs[0].properties[0];

        property.value = allText;

        scope.control.config = {
            "source-property": "bodyText",
            "fragments": 2
        };

        gridModel.value.sections[0].rows[0].areas[0].controls = [
            {
                editor: {
                    alias: "flowingText"
                },
                config: {
                    "source-property": "bodyText",
                    fragments: 1
                }
            },
            scope.control
        ];

        scope.$digest();

        expect(scope.control.value).toBe(expectedText);
    });

});

I've added the grid model to the root scope. It'll be there on scope.model in the controller. So this test inserts another editor before the one under test. It shows one fragment, so our instance under test should show the two next ones.

The code to satisfy this is by far the most incompatible with Archetype and Nested Context. I hope some of you guys find this interesting enough to hunt for a nice solution for those. Give me a shout if you need a hand.

Again, there's a lot of code omitted by now, but you can find it all on GitHub.

For the controller, I've added this big traversing method that finds the previous editors' fragment counts. It could most definitely be prettified, but I'm in a hurry to get this post to market and myself to bed. Here's the entire nested mess of it:

        function alreadyRenderedFragments() {
            var sections = scope.model.value.sections,
                section, si, row, ri, area, ai, control, ci,
                fragments = 0;
            for (si = 0; si < sections.length; si++) {
                section = sections[si];
                for (ri = 0; ri < section.rows.length; ri++) {
                    row = section.rows[ri];
                    for (ai = 0; ai < row.areas.length; ai++) {
                        area = row.areas[ai];
                        for (ci = 0; ci < area.controls.length; ci++) {
                            control = area.controls[ci];
                            if (control === model) {
                                return fragments;
                            }
                            if (control.editor.alias === model.editor.alias &&
                                control.config["source-property"] === model.config["source-property"]) {
                                fragments += control.config.fragments;
                            }
                        }
                    }
                }
            }
            return fragments;
        }

The only change to the rest of the controller is that updateFlow now initializes begin to the result of alreadyRenderedFragments.

                ...
                begin = alreadyRenderedFragments(),
                ...

Now updating our text pages gives us something more interesting:

Content text spread over two columns with a macro between last two paragraphs

Mesa like! There's the macro we don't want in the middle of our RTEs. Glimmering between the second and third paragraph of the body text.

Rounding up

There's a few more issues to tackle here. We should allow blank fragment configurations to represent "to the end of the text" or something along those lines. We should inform the user that there is no more fragments to render. I guess there's no real end to this. But I'm certainly satisfied with the proof of concept, and excited to see if it spawns some nice collaboration in the community.

What I'd really like next for this tool, is to make it so the first editor you add show everything by default. Closing the settings dialog and hovering the editor shows a scissor and a line between the fragments where you're currently hovering. Clicking splits the editor in two and let's you move the second one to another area. That would hopefully be a pleasant user experience.

I hope you enjoyed tagging along with me for this experiment. I certainly enjoyed bringing you. And thanks a lot for staying with me if you got this far. I'll see about continuing this, but for now, head over to GitHub, fork the project and have some fun with it.

Here's a video showing the final spin of the code so far.

Author

comments powered by Disqus