Map/Dictionary fields?

I have a hugo website that uses a map in it’s front-matter. A map like a bunch of key-value pairs, not like a world map.

So my frontmatter looks like:

---
title: My Title
dictionary:
    key1: some description
    anotherKey: unique to this file
    babe: A great movie
---

Is there a way of defining such a structure using Netlify? They can be unique to the file, but also shared with other files.

If you’re looking to recreate this dataset with Netlify CMS: try the object widget!

Here’s an example collection (posts) with a title and an object:

collections:
  - name: "post"
    label: "Post"
    folder: "site/content/post"
    slug: '{{title}}'
    fields:
      - {name: title, label: title, widget: string}
      - name: dictionary
        label: Dictionary
        widget: object
        fields: 
          - {name: key1, label: Key 1, widget: string}
          - {...}

Ah yes, I confuse netlify.com and Netlify CMS.

I saw Object, but that only has fixed fields right? I can’t have one file with key1: value and a different file with different key: value? And on top of that, I don’t know the keys beforehand. Any arbitrary key should be allowed.

However, I’ll look at the code of object to see if I can make my own version. Thanks!

Ah yes, you’re right: the fields of an object widget are fixed, there’s no support for variable object keys yet. You might want to look into the variable types list widget. The data structure is somewhat different, but it might be more suitable for your use case: Beta Features! | Netlify CMS | Open-Source Content Management System

Thanks! At first glance it looks like I’ll need to make a custom widget. That’s ok, more to learn :smiley:

2 Likes

So I’ve been trying to make a new widget, but I’m running in some problems. I’m mostly basing this on Creating Custom Widgets | Netlify CMS | Open-Source Content Management System

So I’m using a Map as the value, but when I add a new element to the map, and then call the onChange(value) callback, nothing seems to happen. However, if I change it to onChange(new Map(value)) it does update. It seems that the onChange callback requires a new object?

Also, the value doesn’t seem to be actually saved. When I fill in other widgets and refresh the page, it then asks to restore the previous values. However it doesn’t restore the map, while restores the other values just fine.

And lastly, I get uncaught exception: Object like a second after I change anything to the map. My guess is that Netlify CMS is trying to save the map (debouncing it for a second so it doesn’t save every letter I type), but fails and throws that exception. That would explain the previous problem (the non-saving one).

My complete code currently is:

    var IngredientsControl = createClass({
        getDefaultProps: function () {
            return {
                value: new Map()
            };
        },

        addElement: function (e) {
            var value = this.props.value;
            value.set("id", "Description");

            //is.props.onChange(value);
            this.props.onChange(new Map(value));
        },

        handleIdChange: function (oldId, newId) {
            console.log(oldId, newId);
            var value = this.props.value;
            var description = value.get(oldId);
            value.delete(oldId);
            value.set(newId, description);

            //this.props.onChange(value);
            this.props.onChange(new Map(value));
        },

        handleDescriptionChange: function (id, description) {
            console.log(id, description);
            var value = this.props.value;
            value.set(id.toLowerCase(), description);

            //this.props.onChange(value);
            this.props.onChange(new Map(value));
        },

        render: function () {
            var value = this.props.value;

            var handleIdChange = this.handleIdChange;
            var handleDescriptionChange = this.handleDescriptionChange;

            var items = [];
            for (var [id, description] of value) {
                var li = h('li', {},
                    h('input', { type: 'text', value: id, onChange: function (e) { handleIdChange(id, e.target.value); } }),
                    h('input', { type: 'text', value: description, onChange: function (e) { handleDescriptionChange(id, e.target.value); } })
                );
                items.push(li);
            }

            return h('div', { className: this.props.classNameWrapper },
                h('input', {
                    type: 'button',
                    value: "Add element",
                    onClick: this.addElement
                }),
                h('ul', {}, items)
            )
        }
    });

    var IngredientsPreview = createClass({
        render: function () {
            var value = this.props.value;
            var items = [];
            for (var [id, description] of value) {
                var li = h('li', {},
                    h('span', {}, id),
                    h('span', {}, ": "),
                    h('span', {}, description)
                );
                items.push(li);
            }

            return h('ul', {}, items);
        }
    });

    CMS.registerWidget('ingredients', IngredientsControl, IngredientsPreview);

What am I doing wrong?

Thanks!

There’s an undocumented onChangeObject prop that gets passed into widgets with map values, try using that instead.

On the backend it seems to change the Map into an object. Very weird.

Initially when I add a new value it seems fine:

Add:
Map { id → "Description" }

(copied from Firefox console)

Though once it enters the render function, the props.value has changed and is now and object, with at it’s root an array of tuples, with the first element of the first tuple being my map:

Render:
{…}
    __altered: false
    __hash: undefined
    __ownerID: undefined
    _root: {…}
        entries: (1) […]
            0: Array [ Map(1), undefined ]
            length: 1
            <prototype>: Array []
        ownerID: undefined
        <prototype>: Object { get: get(), update: update(), iterate: iterate(), … }
    size: 1
    <prototype>: Object { constructor: Fe(), toString: toString(), get: get(), … } 

Adding a second element makes it go all wonky, which makes sense I guess since I’m assuming it’s a map but it isn’t any more. Hard to explain, but here’s a screenshot:

image

Again, here is the new code, so you can see exactly where I log “Add” and “Render”:

    var IngredientsControl = createClass({
        getDefaultProps: function () {
            return {
                value: new Map()
            };
        },

        addElement: function (e) {
            var value = this.props.value;
            value.set("id", "Description");

            console.log("Add: ");
            console.log(value);

            this.props.onChangeObject(value);
            //this.props.onChange(new Map(value));
        },

        handleIdChange: function (oldId, newId) {
            console.log(oldId, newId);
            var value = this.props.value;
            var description = value.get(oldId);
            value.delete(oldId);
            value.set(newId, description);

            this.props.onChangeObject(value);
            //this.props.onChange(new Map(value));
        },

        handleDescriptionChange: function (id, description) {
            console.log(id, description);
            var value = this.props.value;
            value.set(id.toLowerCase(), description);

            this.props.onChangeObject(value);
            //this.props.onChange(new Map(value));
        },

        render: function () {
            var value = this.props.value;

            console.log("Render: ");
            console.log(value);
            //console.log(this.props);

            var handleIdChange = this.handleIdChange;
            var handleDescriptionChange = this.handleDescriptionChange;

            var items = [];
            for (var [id, description] of value) {
                var li = h('li', {},
                    h('input', { type: 'text', value: id, onChange: function (e) { handleIdChange(id, e.target.value); } }),
                    h('input', { type: 'text', value: description, onChange: function (e) { handleDescriptionChange(id, e.target.value); } })
                );
                items.push(li);
            }

            return h('div', { className: this.props.classNameWrapper },
                h('input', {
                    type: 'button',
                    value: "Add element",
                    onClick: this.addElement
                }),
                h('ul', {}, items)
            )
        }
    });

    var IngredientsPreview = createClass({
        render: function () {
            var value = this.props.value;
            console.log("Preview: ");
            console.log(value);
            var items = [];
            for (var [id, description] of value) {
                var li = h('li', {},
                    h('span', {}, id),
                    h('span', {}, ": "),
                    h('span', {}, description)
                );
                items.push(li);
            }

            return h('ul', {}, items);
        }
    });

    CMS.registerWidget('ingredients', IngredientsControl/*, IngredientsPreview*/);

Looked into it a bit more - you do not need to use onChangeObject for a case where your widget is reading and writing a map, only if you’re allowing nested widgets to control the individual values within the map like the object widget does.

Your onChange calls should look something like this:

// Map function is imported from immutable
this.props.onChange(this.props.value || Map()).set(key, value)

Using the Immutable.Map seems to work indeed! Thanks a lot :smiley:

Can you post your final code?

var IngredientsControl = createClass({
    getDefaultProps: function () {
        return {
            value: Immutable.OrderedMap()
        };
    },

    addElement: function (id, description) {
        const value = this.props.value;
        id = id.toLowerCase();
        if (!value.has(id)) {
            const newValue = value.set(id, description);
            this.props.onChange(newValue);
        }
    },

    removeElement: function (id) {
        id = id.toLowerCase()
        const newValue = this.props.value.filter((d, i) => i.toLowerCase() != id);
        this.props.onChange(newValue);
    },

    handleIdChange: function (oldId, newId) {
        const value = this.props.value;
        if (!value.has(newId)) {
            const newValue = value.mapKeys(id => id.toLowerCase() === oldId.toLowerCase() ? newId.toLowerCase() : id.toLowerCase());
            this.props.onChange(newValue);
        }
    },

    handleDescriptionChange: function (id, description) {
        const newValue = this.props.value.set(id.toLowerCase(), description);
        this.props.onChange(newValue);
    },

    renderIngredientListElement(id, description) {
        const removeElement = this.removeElement;
        const handleIdChange = this.handleIdChange;
        const handleDescriptionChange = this.handleDescriptionChange;

        return h('li', { style: { display: 'flex' } },
            h('button', {
                tabindex: -1,
                onClick: (function () { removeElement(id); })
            }, "✖"),
            h('input', {
                type: 'text',
                value: id,
                placeholder: "ingredient id",
                onChange: (function (e) { handleIdChange(id, e.target.value); })
            }),
            h('span', {}, ": "),
            h('input', {
                type: 'text',
                value: description,
                placeholder: "Description",
                style: { 'flex-grow': '1' },
                onChange: (function (e) { handleDescriptionChange(id, e.target.value); })
            })
        );
    },

    renderNewIngredientListElement() {
        const addElement = this.addElement;

        return h('li', { style: { display: 'flex' } },
            h('button', { disabled: true }, "▶"),
            h('input', {
                type: 'text',
                placeholder: "ingredient id",
                onChange: (function (e) { addElement(e.target.value, "Description"); })
            }),
            h('span', {}, ": "),
            h('input', {
                type: 'text',
                placeholder: "Description",
                style: { 'flex-grow': '1' },
                disabled: true
            })
        );
    },

    render: function () {
        const value = this.props.value;

        // Render existing ingredients
        let ingredientElements = [];
        for (const [id, description] of value) {
            const li = this.renderIngredientListElement(id, description);
            ingredientElements.push(li);
        }

        // Render the "new" ingredient input
        const addElement = this.addElement;
        const li = this.renderNewIngredientListElement();
        ingredientElements.push(li);

        // Put them into an ul element
        let divElements = [
            h('ul', {}, ingredientElements)
        ];

        // Optionally add explanation
        if (this.props.field.get("show_info", true) == true) {
            divElements.push(
                h('div', {}, "Ids should be plural by default, ei. use `potatoes` not `potato`, though there are exceptions such as `chicken` where the singular is used. These ids will be used to filter, so try not to make duplicates and use your best judgement as to what sounds best. Check existing recipes for examples.")
            );
        }

        // Wrap everything in a div
        return h('div', { className: this.props.classNameWrapper }, divElements);
    }
});

CMS.registerWidget('ingredients', IngredientsControl);
3 Likes

I’d love to see this packaged and considered to be added to the CMS. When I was writing GitHub - basa-casa/hugo-scms-admin: Hugo module that adds an /admin section made of NetlifyCMS (soon StaticJsCMS) pages. Build your own CMS and manage hugo configuration, content, and data., I used variable type list fields. The module allows for the import and extension of existing fields and collections using an import field, and its extend: object can either be an object field with all of the collection options and field options as fields (one per field option, all widgets), or it can use this widget and allow arbitrary mappings to be entered.

When I create file collections for Hugo configs, the Params object will also need to be able to collect arbitrary mappings.

1 Like