n0minal
n0minal

Reputation: 3223

format string in javascript

Is it possible to achieve this in javascript?

Here is the format:

1 ITEM1      9.00   0%  9.00
1 ITEM-GET01 8.00   12% 5.00

I would like to create a receipt like layout. I'm using backbone.js so there will be models and collection involve.

Also if it were on a table can I use jquery to get the data from the table tr then have the result just like what is posted above?

I was able to read about sprintf but I don't think it it is the one I need. any ideas?

UPDATE

I'm trying out sprintf here is what i've come so far

var result = sprintf("%d %-s %.2f %d%% %.2f", model.get("Qty"), model.get("Itemname"), model.get("Price"), model.get("Discount"), model.get("ExtPrice"));

result is:

1 Item1 1.49 0% 1.49

Upvotes: 3

Views: 11794

Answers (2)

Julian
Julian

Reputation: 4366

Tutorial alert!

This is a four-part answer: the first two sections detail how the canonical way of working with Backbone touches on the question, with the first section laying the foundations for the remaining sections. The last two sections discuss the "special" aspects of the question: rendering to aligned plain text and reading from HTML.

1. The model

From the question, it appears that a plain JSON version of the data looks like this:

var plainData = [{
    Qty: 1,
    Itemname: 'ITEM1',
    Price: 9.00,
    Discount: 0,
    ExtPrice: 9.00
}, {
    Qty: 1,
    Itemname: 'ITEM-GET01',
    Price: 8.00,
    Discount: 12,
    ExtPrice: 5.00
}];

Also from the question, I get the strong impression that these data are somehow loaded into a collection of models. It might be through a direct .set(), by fetching from a server or otherwise. For this answer, I will simply construct a plain Backbone.Collection directly with these data:

var collection = new Backbone.Collection(plainData);

The Collection class automatically puts each object in the array in a separate model, so when I do

var model = collection.at(0);

I get an instance of Backbone.Model, and when I do

model.toJSON();

I get a copy of the first object in plainData, i.e., {Qty: 1, Itemname: 'ITEM1', Price: 9.00, Discount: 0, ExtPrice: 9.00}. I can also get just one attribute with model.get('Qty'), or a complete copy of plainData by calling collection.toJSON().

Backbone is quite unique among client side frameworks in having dedicated classes for the model layer of the application. I personally believe this is also one of its main selling points, as the model layer is (or should be) the foundation of any application. In each of the following sections, we will be either extracting data from, or adding data to these models, illustrating their central role.

2. Rendering to HTML

In a very typical Backbone application, a Backbone.View will have access to either a Model or a Collection (or sometimes both), from which it extracts data to display in HTML. The relevant portion of the view class definition often looks like this, if we are rendering a single model per view:

var ItemView = Backbone.View.extend({
    tagName: 'tr', // outer element of the view
    template: ..., // we will discuss this next
    initialize: function() {
        // It is a good habit to make a view responsible for rendering
        // itself. If not too expensive, it should be done during
        // construction and after every model change.
        this.render().listenTo(this.model, 'change', this.render);
    },
    render: function() {
        // 1. extract data from model (model.toJSON);
        // 2. turn data into HTML code (this.template);
        // 3. set as internal structure of the view's element ($el.html).
        this.$el.html(this.template(this.model.toJSON()));
        // Conventionally, return this so we can chain other methods
        // after this.render.
        return this;
    }
});

Quick note on the class emulation: you can also use JavaScript's native class emulation with Backbone, but it is less convenient. This is because the ECMAScript standards committee made the unfortunate choice to use class fields as constructor initializers instead of as prototype properties. The above class would look as follows:

class ItemView extends Backbone.View {
    render() {
        this.$el.html(this.template(this.model.toJSON()));
        return this;
    }
}

ItemView.prototype.tagName = 'tr';
ItemView.prototype.template = ...;

For convenience of notation, I will stick with Backbone's own class emulation in this answer.

By convention, the template is a function that takes JSON as an argument and that returns a raw HTML string. It is usually compiled from a template string, either using Underscore's _.template (which is already there because Backbone depends on Underscore anyway) or some flavor of Mustache (which has much nicer syntax and features), such as Handlebars or Wontache. I illustrate both flavors below:

var ItemView = Backbone.View.extract({
    // Underscore flavor
    template: _.template('<td><%= Qty %></td><td><%= Itemname %></td><td><%= Price %></td><td><%= Discount %></td><td><%= ExtPrice %></td>');
    // Mustache flavor
    template: mustache('<td>{{Qty}}</td><td>{{Itemname}}</td><td>{{Price}}</td><td>{{Discount}}</td><td>{{ExtPrice}}</td>');
});

In the above example, I inlined the template strings inside the class definition for simplicity. Usually, you will instead save the templates in separate files and use a suitable plugin for a build tool of choice to have those files converted into modules that you can import the template from as a compiled, ready-to-use function. How to do this is beyond the scope of this answer, but for illustration, I will mention the Rollup plugin for Wontache as one of the many options.

In this particular case, you could skip the dedicated template method and generate the HTML directly in the render method using a template literal:

var ItemView = Backbone.View.extend({
    render: function() {
        var {Qty, Itemname, Price, Discount, ExtPrice} = this.model.toJSON();
        this.$el.html(`<td>${Qty}</td><td>${Itemname}</td><td>${Price}</td><td>${Discount}</td><td>${ExtPrice}</td>`);
        return this;
    }
});

However, in general I do not recommend this, because this gives you less separation of concerns. It also works only in the simplest cases, when you don't have any conditional or repeated parts in the template.

Note that I excluded the outer <tr></tr> wrapper in the above examples. I don't need to render it, because I already get that element for free because I set tagName: 'tr'. Despite the above templates missing the <tr>, it still appears in the view's HTML when I render it:

var anItemView = new ItemView({model: collection.at(0)});
anItemView.render().$el.html();
// <tr><td>1</td><td>ITEM1</td><td>9.00</td><td>0</td><td>9.00</td></tr>

Remember this: a Backbone.View template should exclude the outer wrapping element. Including it is a common mistake, which leads to superfluous <div> wrappers in the HTML and a superfluous indentation level in the template code.

We now have a view that renders a single item in the receipt to HTML. To render the entire receipt with multiple <tr> elements in a single <tbody> or <table>, we have two main options.

Option 1: nested views

For modularity, it can be desirable to have a view that is responsible for rendering the entire collection, which then defers rendering each model inside the collection to a separate subview. For the subviews, we could use the above ItemView class in this case. There are libraries that make it easy to manage this, such as Marionette and backbone-fractal. Below, I illustrate this option with backbone-fractal:

import { CollectionView } from 'backbone-fractal';

var ReceiptView = CollectionView.extend({
    tagName: 'tbody',
    subview: ItemView, // our single-model view from above
    initialize: function() {
        this.initItems().render().initCollectionEvents();
    }
    // In this simple case, we don't need to do anything else!
});

// Instantiate like this:
var aReceiptView = new ReceiptView({collection: collection});

Rendering the above instance of the collection view will produce the following HTML:

<tbody>
    <tr><td>1</td><td>ITEM1</td><td>9.00</td><td>0</td><td>9.00</td></tr>
    <tr><td>1</td><td>ITEM-GET01</td><td>8.00</td><td>12</td><td>5.00</td></tr>
</tbody>

Option 2: single view with looping template

In simple cases, a single view that renders the entire chunk of HTML at once can also be defensible. We just pass the entire collection to the view instead of a single model, and write a larger template that takes care of the repetition. Below, I illustrate this option with a Mustache template.

// The template would normally be imported from an external module.
var receiptTemplate = mustache(`
    {{#.}} {{! this begins the loop }}
    <tr> {{! wrapping TR needed because it is no longer
             the outer element of a view }}
        {{! content of the TR is as before (wrapped here for clarity) }}
        <td>{{Qty}}</td>
        <td>{{Itemname}}</td>
        <td>{{Price}}</td>
        <td>{{Discount}}</td>
        <td>{{ExtPrice}}</td>
    </tr>
    {{/.}} {{! this ends the loop}}
`);

var ReceiptView = Backbone.View.extend({
    tagName: 'tbody', // we get this element for free again
    template: receiptTemplate,
    render: function() {
        // Nearly the same line as before, but this.collection
        // instead of this.model.
        this.$el.html(this.template(this.collection.toJSON()));
        return this;
    }
});

3. Rendering to space-aligned plaintext

The HTML approach in the previous section makes the browser responsible for cell alignment and is the most suitable for presentation on the web. However, it is conceivable that you might want to render space-aligned plaintext instead, for example as terminal output, as asked in the question (I changed the format to right-align the discount percentage):

1 ITEM1      9.00   0%  9.00
1 ITEM-GET01 8.00  12%  5.00

sprintf-js is a suitable library for this usecase, because its format strings can add padding for alignment. In good modular style, let's first write a function that takes the plain JSON of a single item as input and returns a space-aligned text row as output:

var itemFormat = '%1d %-10s %4.2f  %2d%%  %4.2f';

function itemPlaintext(item) {
    var {Qty, Itemname, Price, Discount, ExtPrice} = item;
    return sprintf(itemFormat, Qty, Itemname, Price, Discount, ExtPrice);
}

Mapping this function over the plain data will give us the exact space-aligned output above:

collection.toJSON().map(itemPlaintext).join('\n');

Alternatively, we could use itemPlaintext as a view template and send the output to the console instead of the DOM (and then use a composite view for repetition):

var ItemView = Backbone.View.extend({
    template: itemPlaintext,
    render: function() {
        console.log(this.template(this.model.toJSON()));
        return this;
    }
});

There is one problem with this: we have hardcoded the column widths. With the particular padding widths I used above, a Qty greater than 9, an Itemname longer than 10 characters, a Price or ExtPrice greater than 9.99 or Discount of 100 will cause misalignment. A simple, but not very robust solution is to use margins on the widths so that most data are likely to fit:

var itemFormat = '%3d %-20s %7.2f %3d%% %7.2f';

A failsafe, but more complicated solution is to first make a pass over the data to gauge the maximum column widths and then generate versions of itemFormat and itemPlaintext on the fly with suitable padding numbers. Ironically, we need to use a template string or template literal in this case in order to generate the format string. I will show one possible way to do this below, which uses Underscore:

// Helper functions
var stringLength = value => String(value).length;
// Same as above, but for each property of an object
var keyLengths = _.partial(_.mapObject, _, stringLength);
// Given two objects, return a merged version that has
// the maximum value of each matching property.
function pairwiseMax(left, right) {
    return _.mapObject(left, function(value, key) {
        return Math.max(value, right[key]);
    });
}

// Putting it all together in magic
function gaugeColumns(plainData) {
    // Step 1: compute the column widths
    var columnWidths = plainData.map(keyLengths).reduce(pairwiseMax);
    var {Qty, Itemname, Price, Discount, ExtPrice} = columnWidths;
    // Step 2: generate a suitable format string
    var itemFormat = `%${Qty}d %-${Itemname}s %${Price + 2}.2f %${Discount}d%% %${ExtPrice + 2}.2f`;
    // Step 3: same function as before, but using the above custom format
    function itemPlaintext(item) {
        var {Qty, Itemname, Price, Discount, ExtPrice} = item;
        return sprintf(itemFormat, Qty, Itemname, Price, Discount, ExtPrice);
    }
    // Step 4: return the function so it can be used
    return itemPlaintext;
}

Quick note: the above code will work reliably, but may result in overly wide columns if you have floating point values that look like 123.000000015. How to prevent this is left as an exercise to the reader. (Hint: you can conditionally use Math.floor inside the stringLength function.)

We now use the output of the gaugeColumns function as our rendering function. The direct mapping scenario would look like this:

var plainData = collection.toJSON();
var itemPlaintext = gaugeColumns(plainData);
plainData.map(itemPlaintext).join('\n');

The nested view template scenario is more complicated, because the collection view (ReceiptView) somehow needs to call gaugeColumns on the fly and pass the result to each of its subviews so it can be used in rendering, possibly every time the contents of the collection change. How to do this depends on the solution that you used for view composition. If using backbone-fractal, it might look like this:

var ReceiptView = CollectionView.extend({
    subview: ItemView, // no change required to the subview
    initialize: function() {
        this.initItems().render().initCollectionEvents();
    },
    afterRender: function() {
        var itemPlaintext = gaugeColumns(this.collection.toJSON());
        this.items.forEach(function(subview) {
            subview.template = itemPlaintext;
            // force re-render after template change
            subview.render();
        });
    }
});

4. Reading data from HTML

Normally, we would read data from a model and write to HTML, not the other way round. However, if for whatever reason you still need to read from HTML, jQuery can help to keep the code concise. In the same spirit as before, let's first consider a function that takes a single <tr> element from the DOM and returns a plain JSON object with the item data:

// Helper: given a DOM element, return its inner text without
// leading or trailing whitespace.
var elemText = (index, element) => $(element).text().trim();

function itemFromRow(trElem) {
    var rawData = $(trElem).find('td').map(elemText).get();
    // We assume the same order as before.
    return {
        Qty: +rawData[0],  // + converts string to number
        Itemname: rawData[1],
        Price: +rawData[2],
        Discount: +(rawData[3].slice(0, -1)), // slice takes off the % character
        ExtPrice: +rawData[4]
    };
}

With this function in place, we can map it over a <tbody> or <table> element to extract an array with all item objects. We can then .set this array to our collection in order to fill or update it. If we have any views that are involved in displaying the receipt, they should update automatically:

// Suppose that receiptData is the id of the <tbody> containing the data
var tbodyElem = $('#receiptData');
// Extract the full data as plain JSON
var plainData = tbodyElem.find('tr').map((i, e) => itemFromRow(e)).get();
// Update collection contents
collection.set(plainData);
// Done!

Upvotes: 0

Diode
Diode

Reputation: 25155

You can do this in different ways. The method usually used is to loop in the data array and add rows in a table in which width of each column is set. Please see the sample in jQuery

var data = [
    {
    no: 1,
    name: "ITEM1",
    price1: "9.00",
    perc: "0%",
    price2: "9.00"},
{
    no: 2,
    name: "ITEM-GET01",
    price1: "9.00",
    perc: "12%",
    price2: "5.00"}
];

//$("#list tr").remove();    
$(data).each(function(index, item) {
    $("#list").append('<tr><td width="50">' + item.no + '</td><td width="100">' + item.name + '</td><td width="100">' + item.price1 + '</td>' + item.perc + '<td width="100">' + item.price2 + '</td></tr>');
})

demo : http://jsfiddle.net/diode/E8a6V/

Upvotes: 0

Related Questions