Managing Complex String Fields with can.Map

Sometimes you might find yourself working with a backend that expects a single string field to represent multiple pieces of information that need to be entered/validated individually. For example, a string that contains both date and time, or address lines separated with special characters. In such an instance, one solution is to change the backend, but sometimes that is simply not an option (due to budget, stubbornness, etc), causing you to work around it on the front end.

For illustration purposes, let’s say the server is going to provide (or will expect) a payload like this:

{
    "id": 1,
    "name": "Bob Smith",
    "interviewDateTime": "2015-11-24 09:30:00"
}

That might be fine if the user has one text box for interview date/time, but what if the design calls for separate date and time fields on the screen and different validation rules for each smaller piece?

There are a few ways you could approach this, but regardless of the method, you will need to take care of 2 things:

  • distributing the combined value into multiple fields when provided by the server
  • combining the various pieces before sending them back to the server

We can take advantage of can.Map's define plugin to do this fairly easily, using set, type or Type for the distribution step, and serialize for the combination step.

Using Additional Properties :anguished:

The straight-forward approach is to create additional properties on your model for each distinct piece, use type (or set but type seems most applicable) to distribute the combined value to the new properties, and serialize to combine them again. The following examples defines interviewDate and interviewTime for this purpose.

var Candidate = can.Map.extend({
    define: {
        interviewDateTime: {
            type: function(str) {
                // parse the input string
                var dateTime = str.split(' ');

                // distribute the distinct pieces to the extra props
                this.attr({
                    interviewDate: dateTime[0],
                    interviewTime: dateTime[1]
                });

                // keep the original
                return str;
            },
            serialize: function(str) {
                // combine both props into a single string for saving
                return this.attr('interviewDate') + ' ' + this.attr('interviewTime');
            }
        },
        // don't send these to the server because it doesn't know about them
        interviewDate: { serialize: false },
        interviewTime: { serialize: false }
    }
});

You would bind it in your template like this:

<date-picker {($value)}="myCandidate.interviewDate"></date-picker>
<time-picker {($value)}="myCandidate.interviewTime"></time-picker>

That takes care of it. When interviewDateTime is set such as when the map is created, it will distribute its pieces out to interviewDate and interviewTime, both of which are distinct fields that can be bound and validated individually. When the candidate is saved, interviewDateTime will be serialized as a single string.

While this solution works and might be fine for a one-off field, what if you need this functionality for multiple fields on multiple models? It would get pretty annoying having to define all those additional properties with unique names, which brings me to the next approach…

Using Nested Properties :grinning:

You can avoid defining additional properties by nesting them under the original in a child map. This can be done by using type to return an object in place of the string.

interviewDateTime: {
    type: function(str) {
        var dateTime = str.split(' ');

        // interviewDateTime will become a map
        return new can.Map({
            date: dateTime[0],
            time: dateTime[1]
        });
    },
    serialize: function(dateTime) {
        return dateTime.attr('date') + ' ' + dateTime.attr('time');
    }
}

You would bind it in your template like this:

<date-picker {($value)}="myCandidate.interviewDateTime.date"></date-picker>
<time-picker {($value)}="myCandidate.interviewDateTime.time"></time-picker>

Okay, cool, but what’s the simplest way to provide this functionality to multiple fields and modules? Creating a global type in can.define.types won’t cover the serialization (and you might have dependency problems if you aren’t careful), so we need a way to share the entire definition object like this:

mapDefinitions.js

export var dateTimeDefinition = {
    type: function(str) {
        var dateTime = str.split(' ');
        return new can.Map({
            date: dateTime[0],
            time: dateTime[1]
        });
    },
    serialize: function(dateTime) {
        return dateTime.attr('date') + ' ' + dateTime.attr('time');
    }
};

Then we might have to merge that object with a custom definition in Candidate.

candidate.js

import { dateTimeDefinition } from 'mapDefinitions';

var Candidate = can.Map.extend({
    define: {
        interviewDateTime: can.extend({}, dateTimeDefinition , {
            // custom stuff for this specific field
            value: function() {/* today, 1 hour from now */}
        }
    }
});

This works and the pattern might come in handy elsewhere but we can simplify it further.

Nesting Properties with a Custom Type :wink:

Leveraging the flexibility of can.Map, we can create a custom Map type that overrides setup and serialize to encapsulate all of the required functionality into a single construct.

util/dateTime.js

export var DateTime = can.Map.extend({
    setup: function(str) {
        var dateTime = str.split(' ');
        this._super({
            date: dateTime[0],
            time: dateTime[1]
        });
    },
    serialize: function() {
        return this.attr('date') + ' ' + this.attr('time')
    }
});

You’ll need the construct/super plugin to do this._super. If you don’t, use can.Map.prototype.setup.call(this, {...}).

candidate.js

import { DateTime } from 'util/dateTime';

var Candidate = can.Map.extend({
    define: {
        interviewDateTime: {
            Type: DateTime,
            value: function() {/* today, 1 hour from now */}
        }
    }
});

Notice the capital Type in Candidate. This is the constructor version of type that coerces any set value through that constructor if the new value is not already an instance. For our purposes, it’s basically taking care of running new DateTime(str) for us when we set a string.

Overriding serialize is great because when it’s called on a can.Map, it’s also called on all child maps recursively. By overriding the method, we can change the behavior so that it returns a string instead of the usual object.

Note: This date-time string example is simplified. You probably want to handle cases such as when the date-time string is incomplete or blank. You could also use a library like Moment to handle parsing.

3 Likes