0

I am trying to update a select box element in my app using Knockoutjs. When you click my recipe id: 31 on this snippet, you can see it updates some form elements near the top, based on the var RECIPE object. However, the Fermentables item names are not updated (they remain as "-"). The Milling preference of these Fermentables does update, however.

The Fermentables html is:

           <div data-bind="foreach: fermentables">
                <select id="fermentable-variety-select" style="width:325px" data-bind="value: catalog_id">
                    <option value="-"> - </option>
                        <!-- ko foreach: fermentables_options -->
                        <optgroup data-bind="attr: {label: category}">
                            <!-- ko foreach: fermentables -->
                                <option data-bind="value: $data.catalog_id, text: $data.name + ' -- ' + $data.price.toFixed(2)"></option>
                            <!-- /ko -->
                        </optgroup>
                    <!-- /ko -->
                </select>
                <label>Milling preference: </label>
                <select data-bind="options: $root.milling_preferences, value: milling_preference"></select>
                <a href="#" data-bind="click: $root.removeFermentable, visible: $root.fermentables.countVisible() > 1">
                    Delete
                </a>
                <br><br>
            </div>

The broken select element is the top one, the working one is the bottom. You can see the broken one is more complex, using a nested foreach, but I needed that to display categories as optgroups.

I do have data-bind="value: catalog_id" on the broken select, but it will not update to any value except "-".

These boxes should display Briess Bavarian Wheat DME 1 Lb and Briess Bavarian Wheat DME 3 LBS.

// hard codes

var HOPS = [
    {
        "category": "Hop Pellets",
        "hops": [
            {
                "name": "Ahtanum Hop Pellets 1 oz",
                "price": 1.99,
                "catalog_id": 1124
            },
            {
                "name": "Amarillo Hop Pellets 1 oz",
                "price": 3.99,
                "catalog_id": 110
            },
            {
                "name": "Apollo (US) Hop Pellets - 1 oz.",
                "price": 2.25,
                "catalog_id": 6577
            },
        ]
    }
]

var FERMENTABLES = [
    {
        "category": "Dry Malt Extract",
        "fermentables": [
            {
                "name": "Briess Bavarian Wheat DME 1 Lb",
                "price": 4.99,
                "catalog_id": 496
            },
            {
                "name": "Briess Bavarian Wheat DME 3 LBS",
                "price": 12.99,
                "catalog_id": 1435
            },
            {
                "name": "Briess Golden Light DME 1 Lb",
                "price": 4.99,
                "catalog_id": 492
            },
        ]
    }
]


var YEASTS = [
    {
        "category": "Dry Beer Yeast",
        "yeasts": [
            {
                "name": "500 g Fermentis Safale S-04",
                "price": 79.99,
                "catalog_id": 6012
            },
            {
                "name": "500 g Fermentis Safale US-05",
                "price": 84.99,
                "catalog_id": 4612
            },
            {
                "name": "500 g Fermentis SafCider Yeast",
                "price": 59.99,
                "catalog_id": 6003
            },
        ]
    }
]
      
      
var RECIPE_DATA = [
    {
        id: 31,
        name: "my recipe ",
        notes: "some notes",
        brew_method: "All Grain",
        boil_time: 60,
        batch_size: "4.00",
        fermentable_selections: [
            {
                catalog_id: 496,
                milling_preference: "Unmilled"
            },
            {
                catalog_id: 1435,
                milling_preference: "Milled"
            }
        ],
        hop_selections: [
            {
                catalog_id: 110,
                weight: "4.00",
                minutes: 35,
                use: "Dry Hop"
            }
        ],
        yeast_selections: [
            {
                catalog_id: 6012
            }
        ]
    }
];


var API_BASE = "127.0.0.1:8000";

ko.observableArray.fn.countVisible = function(){
    return ko.computed(function(){
        var items = this();

        if (items === undefined || items.length === undefined){
            return 0;
        }

        var visibleCount = 0;

        for (var index = 0; index < items.length; index++){
            if (items[index]._destroy != true){
                visibleCount++;
            }
        }

        return visibleCount;
    }, this)();
};

function Fermentable(data) {
    var self = this;
    var options = data.options;
    self.fermentables_options = ko.computed(function(){
        return options;
    });
    self.catalog_id = ko.observable(data.catalog_id || "");
    self.name = ko.observable(data.name || "");
    self.milling_preference = ko.observable(data.milling_preference || "Milled");

    self.is_valid = ko.computed(function(){
        var valid = self.catalog_id() !== "" && self.catalog_id() !== "-";
        return valid
    });
}

function Hop(data) {
    var self = this;
    self.hops_options = ko.computed(function(){
        return data.options;
    });
    self.catalog_id = ko.observable(data.catalog_id || "");
    self.name = ko.observable(data.name || "");
    self.amount = ko.observable(data.amount || "");
    self.time = ko.observable(data.time || "");
    self.use = ko.observable(data.use || "Boil");

    self.is_valid = ko.computed(function(){
        var valid = self.amount() > 0 && self.catalog_id() !== "" && self.catalog_id() !== "-";
        return valid
    });
}

function Yeast(data){
    var self = this;
    var permanent_yeasts_options = data.yeasts_options;
    self.catalog_id = ko.observable(data.catalog_id || "");
    self.name = ko.observable(data.name || "-");
    self.current_filter = ko.observable("-Any-");
    self.yeast_groups_individual = ko.computed(function(){
        if (self.current_filter() !== "-Any-"){
            var options = _.filter(data.yeasts_options, function(option){
                return option.category === self.current_filter();
            });
            return options;
        } else{
                return permanent_yeasts_options;
            }
        }
    );
    self.yeast_categories = ko.observableArray();
    ko.computed(function(){
        var starter_list = ['-Any-'];
        var categories = _.pluck(permanent_yeasts_options, 'category');
        var final = starter_list.concat(categories);
        self.yeast_categories(final);
    });

    self.is_valid = ko.computed(function(){
        var valid = self.catalog_id() !== "" && self.catalog_id() !== "-";
        return valid
    });
}

function RecipeViewModel() {

    var self = this;

    // http://stackoverflow.com/questions/14220321/how-do-i-return-the-response-from-an-asynchronous-call
    self.get_data = function(url_ending){
        var URL = "http://%/api/&/".replace("&", url_ending).replace("%", API_BASE);
        var data =  $.ajax({
          dataType: "json",
          url: URL,
          async: false,
        });
        return data.responseJSON;
    }

    self.styles = [
        "--",
        "Standard American Beer",
        "International Lager",
        "Czech Lager",
        "Pale Malty European Lager",
        "Pale Bitter European Beer",
        "Amber Malty European Lager",
        "Amber Bitter European Lager",
        "Dark European Lager"
    ]
    self.styles_data = ko.observableArray();
    ko.computed(function(){
        var data = [];
        for (i = 0; i < self.styles.length; i++){
            var text = self.styles[i];
            if (text === "--"){
                data.push({value: "--", display: "--"});
            } else {
                var display_text = i.toString() + ". " + text;
                var this_entry = {value: text, display: display_text};
                data.push(this_entry);
            }
        }
        self.styles_data(data);
    });

    self.recipes = ko.observableArray();
    // self.recipes( self.get_data("recipes/all-recipes") );
    self.recipes(RECIPE_DATA);

    self.current_style = ko.observable("--");

    // defaults
    self.total_price = ko.observable(0.0); // TODO: this should not default if the recipe has items already...
    self.hops_uses = ko.observableArray(['Boil', 'Dry Hop']);
    self.weight_units = ko.observableArray(['oz', 'lb']);
    self.milling_preferences = ko.observableArray(['Milled', 'Unmilled']);
    self.brew_methods = ko.observableArray(['Extract', 'Mini-Mash', 'All Grain', 'Brew-in-a-bag']);

    // start of input fields
    self.name = ko.observable("");
    self.brew_method = ko.observable("Extract");
    self.batch_size = ko.observable("5");
    self.beer_style = ko.observable("Standard American Beer");
    self.boil_time = ko.observable("60");

    self.notes = ko.observable("");
    self.hops_options = HOPS;
    self.hops = ko.observableArray([new Hop({options: self.hops_options}), new Hop({options: self.hops_options})]);

    self.fermentables_options = FERMENTABLES;
    self.fermentables = ko.observableArray(
        [
            new Fermentable({options: self.fermentables_options}),
            new Fermentable({options: self.fermentables_options})
        ]
    );

    self.yeasts_options = YEASTS;
    self.yeasts = ko.observableArray([new Yeast({yeasts_options: self.yeasts_options})]);

    self.reset_form = function(){
        var x = 'finish this';
    }

    self.populate_recipe = function(data, event){
        var context = ko.contextFor(event.target);
        var index = context.$index();
        var recipe = self.recipes()[index];
        var attrs = ['name', 'brew_method', 'boil_time', 'batch_size', 'notes']
        for (i = 0; i < attrs.length; i++) {
            attr = attrs[i];
            self[attr](recipe[attr]);
        }

        fermentables_data = recipe.fermentable_selections;
        new_fermentables_data = [];
        for (i = 0; i < fermentables_data.length; i++) {
            var data_set = fermentables_data[i]
            data_set['options'] = self.fermentables_options;
            // takes {options: ..; catalog_id: ..; milling_preference}
            // based on the results of http://127.0.0.1:8000/api/recipes/all-recipes/
            var this_fermentable = new Fermentable(data_set);
            new_fermentables_data.push(this_fermentable);
        }
        self.fermentables(new_fermentables_data);
    }

    self.delete_recipe = function(data, event){
        var recipe_id = data.id;
        var URL = "http://%/api/recipes/delete/&/".replace("%", API_BASE).replace("&", recipe_id);
        $.ajax({
          url: URL,
          async: false,
        });

        self.recipes( self.get_data("recipes/all-recipes") );
    }

    self.valid_items = function(items){
        var final_items = _.filter(items, function(item){
            return item.is_valid();
        });
        return final_items;
    }

    self.valid_fermentables = ko.observableArray();
    ko.computed(function(){
        self.valid_fermentables(self.valid_items(self.fermentables()));
    });

    self.valid_hops = ko.observableArray();
    ko.computed(function(){
        self.valid_hops(self.valid_items(self.hops()));
    });

    self.valid_yeasts = ko.observableArray();
    ko.computed(function(){
        self.valid_yeasts(self.valid_items(self.yeasts()));
    });

    self.prices_hash = ko.computed(function(){
        var data = {};

        var strings = ['fermentables', 'hops', 'yeasts'];
        for (i = 0; i < strings.length; i++) {
            var string = strings[i];
            var attr = strings[i] + '_options';
            for (j = 0; j < self[attr].length; j++) {
                var groups = self[attr][j][string];
                for (k = 0; k < groups.length; k++) {
                    var catalog_id = groups[k].catalog_id.toString();
                    var current_price = groups[k].price;
                    data[catalog_id] = current_price;
                }
            }
        }
        return data;
    });

    self.current_price = ko.computed(function(){
        var total_price = 0;

        for (i = 0; i < self.valid_fermentables().length; i++){
            var item = self.valid_fermentables()[i];
            total_price = total_price + self.prices_hash()[item.catalog_id()];
        }

        for (i = 0; i < self.valid_hops().length; i++){
            var item = self.valid_hops()[i];
            total_price = total_price + self.prices_hash()[item.catalog_id()];
        }

        for (i = 0; i < self.valid_yeasts().length; i++){
            var item = self.valid_yeasts()[i];
            total_price = total_price + self.prices_hash()[item.catalog_id()];
        }

        return total_price.toFixed(2);
    });

    self.addFermentable = function(){
        self.fermentables.push(new Fermentable({options: self.fermentables_options}))
    }

    self.addYeast = function(){
        self.yeasts.push(new Yeast({yeasts_options: self.yeasts_options}));
    }

    self.addHop = function(){
        self.hops.push(new Hop({options: self.hops_options}));
    }

    self.removeFermentable = function(fermentable){
        self.fermentables.destroy(fermentable);
    }

    self.removeYeast = function(yeast){
        self.yeasts.destroy(yeast);
    }

    self.removeHop = function(hop){
        self.hops.destroy(hop);
    }

    // http://stackoverflow.com/questions/40501838/pass-string-parameters-into-click-binding-while-retaining-default-params-knockou
    self.removeItem = function(item, name){
        // not finished
        name.remove(function(hop){
            return hop.name === item.name;
        });
    }

    self.purify_fermentables = function(fermentables){
        var final_fermentables = [];
        for (i = 0; i < fermentables.length; i++){
            var item = fermentables[i];
            var object = {catalog_id: item.catalog_id, milling_preference: item.milling_preference};
            final_fermentables.push(object);
        }
        return final_fermentables;
    }

    self.purify_hops = function(hops){
        var final_hops = [];
        for (i = 0; i < hops.length; i++){
            var item = hops[i];
            var object = {catalog_id: item.catalog_id, amount: item.amount, time: item.time, use: item.use};
            final_hops.push(object);
        }
        return final_hops;
    }

    self.purify_yeasts = function(yeasts){
        var final_yeasts = [];
        for (i = 0; i < yeasts.length; i++){
            var item = yeasts[i];
            var object = {catalog_id: item.catalog_id};
            final_yeasts.push(object);
        }
        return final_yeasts;
    }


    self.prepareJSON = function(){
        // pure as in only the fields the server cares about
        var pure_fermentables = self.purify_fermentables(self.valid_fermentables());
        var pure_hops = self.purify_hops(self.valid_hops());
        var pure_yeasts = self.purify_yeasts(self.valid_yeasts());

        object = {
            fermentables: pure_fermentables,
            hops: pure_hops,
            yeasts: pure_yeasts,
            name: self.name(),
            brew_method: self.brew_method(),
            batch_size: self.batch_size(),
            beer_style: self.beer_style(),
            boil_time: self.boil_time(),
            notes: self.notes(),
        }
        return object;
    }

    self.saveRecipeData = function(){
        var recipe_data = ko.toJSON(self.prepareJSON());
        // alert("This is the data you're sending (universal Javascript object notation):\n\n" + recipe_data)
        $.ajax({
            url: "http://127.0.0.1:8000/api/recipes/receive-recipe/",
            headers: {
                "Content-Type": "application/json"
            },
            method: "POST",
            dataType: "json",
            async: false,
            data: recipe_data,
            success: function(data){
                console.log("Success! Saved the recipe");
            }
        });
        // http://stackoverflow.com/questions/951021/what-is-the-javascript-version-of-sleep
        // not working in browser...
        // await sleep(2000);
        self.recipes( self.get_data("recipes/all-recipes") );
    }

    self.my_to_json = function(object){
        return JSON.stringify(object, null, 4);
    }
}
ko.applyBindings(new RecipeViewModel());
        input[type="number"] {
            -moz-appearance: textfield;
        }
        input[type="number"]::-webkit-outer-spin-button,
        input[type="number"]::-webkit-inner-spin-button {
            -webkit-appearance: none;
            margin: 0;
        }

        input, select {
            border-radius: 3px;
        }

        #notes-input {
            width: 650px;
            height: 220px;
        }

        .label-text {
            /*font-weight: bold;*/
        }

       
<head>

    <style>


    </style>
    <script type='text/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.1/knockout-min.js'></script>
    <script type='text/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'></script>
    <script type='text/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js'></script>
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>

</head>

<body>
    <div class="container">
        <div class="row">
            <h3>My Recipes</h3>
            <ul data-bind="foreach: recipes">
                <li>
                    <!-- http://stackoverflow.com/questions/13054878/knockout-js-how-to-access-index-in-handler-function -->
                    <a data-bind="click: $root.populate_recipe">
                        <span data-bind="text: $data.name + '  id: ' + $data.id"></span>
                    </a>
                    <a data-bind="click: $root.delete_recipe">Delete Recipe</a>
                </li>
            </ul>
        </div>

        <div class="row">
            <br><br>
            <div class="col-md-2 col-md-offset-2">
                <span class="label-text">Recipe Name:</span>
            </div>

            <div class="col-md-2">
                <input type="text" data-bind="value: name" maxlength="250" class="recipeSetupText" />
            </div>

            <div class="col-md-4">
                <span class="label-text">Brew Method:</span>

                <select data-bind="options: brew_methods, value: brew_method"></select>
            </div>
        </div>

        <div class="row">

            <!-- http://stackoverflow.com/questions/8354975/how-can-i-limit-possible-inputs-in-a-html5-number-element -->

            <div class="col-md-2 col-md-offset-2">
                <span class="label-text" id="batch-size-label">Batch Size:</span>
            </div>

            <div class="col-md-2">
                <input type="number" data-bind="value: batch_size" style="width: 35px" /> <span class="unit">gallons</span>
            </div>

            <div class="col-md-4">
                <span class="label-text">Style:</span>
                <select data-bind="options: styles_data, optionsValue: 'value', optionsText: 'display', value: current_style"></select>
            </div>
        </div>

        <div class="row">
            <div class="col-md-4 col-md-offset-2">
                <span class="label-text" id="boil-time-label">Boil Time:</span>
                <input type="number" data-bind="value: boil_time" style="width: 60px" /> <span class="unit">(minutes)</span>
            </div>
        </div>

        <h2>Current price: <span data-bind="text: current_price"></span></h2>

        <div>
            <h2>Fermentables</h2>
            <div data-bind="foreach: fermentables">
                <select id="fermentable-variety-select" style="width:325px" data-bind="value: catalog_id">
                    <option value="-"> - </option>
                        <!-- ko foreach: fermentables_options -->
                        <optgroup data-bind="attr: {label: category}">
                            <!-- ko foreach: fermentables -->
                                <option data-bind="value: $data.catalog_id, text: $data.name + ' -- ' + $data.price.toFixed(2)"></option>
                            <!-- /ko -->
                        </optgroup>
                    <!-- /ko -->
                </select>
                <label>Milling preference: </label>
                <select data-bind="options: $root.milling_preferences, value: milling_preference"></select>
                <a href="#" data-bind="click: $root.removeFermentable, visible: $root.fermentables.countVisible() > 1">
                    Delete
                </a>
                <br><br>
            </div>
            <input data-bind="click: addFermentable" type="button" value="Add Fermentable"/>
        </div>

        <div class="row">
            <h2 class="">Yeast</h2>
            <div data-bind="foreach: yeasts">
                <span>Yeast Brand Filter:</span>
                <select data-bind="options: yeast_categories, value: current_filter" id="yeast-brand-select">
                </select>
                <br/>
                <span>Yeast Variety:</span>
                <select id="yeast-variety-select" style="width:325px" data-bind="value: catalog_id">
                    <option value="-"> - </option>
                        <!-- ko foreach: yeast_groups_individual -->
                        <optgroup data-bind="attr: {label: category}">
                            <!-- ko foreach: yeasts -->
                                <option data-bind="value: $data.catalog_id, text: $data.name + ' -- ' + $data.price.toFixed(2)"></option>
                            <!-- /ko -->
                        </optgroup>
                    <!-- /ko -->
                </select>
                <a href="#" data-bind="click: $root.removeYeast, visible: $root.yeasts.countVisible() > 1">Delete</a>
                <br><br>
            </div>
            <br>
            <input data-bind="click: addYeast" type="button" value="Add Yeast"/>
        </div>

        <div class="row">
            <h2 class="">Hops</h2>
            <div data-bind='foreach: hops'>
            <select id="hops-variety-select" style="width:325px" data-bind="value: catalog_id">
                <option value="-"> - </option>
                    <!-- ko foreach: hops_options -->
                    <optgroup data-bind="attr: {label: category}">
                        <!-- ko foreach: hops -->
                            <option data-bind="value: $data.catalog_id, text: $data.name + ' -- ' + $data.price.toFixed(2)"></option>
                        <!-- /ko -->
                    </optgroup>
                <!-- /ko -->
            </select>
            <label>Amount:</label>
            <input type="number" data-bind="value: amount" maxlength="6"> oz

            Time: <input type="text" data-bind="value: time" >
            Min.
            Use:  <select data-bind="options: $root.hops_uses, value: use"></select>

            <a href="#" data-bind="click: function() { $root.removeItem($data, $root.hops) }, visible: $root.hops.countVisible() > 1">Delete</a>

            <br><br>
        </div>

        <br>

        <input data-bind="click: addHop" type="button" value="Add Hop" />
    </div>

    <br>

    <textarea data-bind="value: notes" id="notes-input" placeholder="Write any extra notes here..." style="resize: both;"></textarea>

    <p>
        <button data-bind="click: saveRecipeData">Save recipe</button>
    </p>

    </div>

    <script src='index.js' type='text/javascript'></script>
</body>
halfer
  • 19,824
  • 17
  • 99
  • 186
codyc4321
  • 9,014
  • 22
  • 92
  • 165
  • This is quite a lot of code for an example, and although it's an old question, we like things to be pared down to a minimum size if possible, to most benefit future readers. Was the snippet intended to run? I just tried running it now, and it crashes with various JS errors. If you can trim down the sample so that the question shows the minimum necessary that would still make sense in the context of the accepted answer, that would be great! Thanks. – halfer Nov 10 '17 at 16:37

1 Answers1

2

The order of bindings in Knockout can sometimes be important. In this case, the value binding of the <select> is run before the <option> elements are set up, so when it tries to bind the value there isn't a matching option.

The fix is to force the descendant elements to be bound before value, which can be accomplished by including another binding on the <select> that simply binds the descendants. You could create a custom binding that does this (based on examples at http://knockoutjs.com/documentation/custom-bindings-controlling-descendant-bindings.html), but the built-in if binding will do the job just fine. Simply make sure it's listed before the value binding.

<select data-bind="if: true, value: theValue">...

There's an old open Knockout issue related to this: https://github.com/knockout/knockout/issues/1243

// hard codes

var HOPS = [
    {
        "category": "Hop Pellets",
        "hops": [
            {
                "name": "Ahtanum Hop Pellets 1 oz",
                "price": 1.99,
                "catalog_id": 1124
            },
            {
                "name": "Amarillo Hop Pellets 1 oz",
                "price": 3.99,
                "catalog_id": 110
            },
            {
                "name": "Apollo (US) Hop Pellets - 1 oz.",
                "price": 2.25,
                "catalog_id": 6577
            },
        ]
    }
]

var FERMENTABLES = [
    {
        "category": "Dry Malt Extract",
        "fermentables": [
            {
                "name": "Briess Bavarian Wheat DME 1 Lb",
                "price": 4.99,
                "catalog_id": 496
            },
            {
                "name": "Briess Bavarian Wheat DME 3 LBS",
                "price": 12.99,
                "catalog_id": 1435
            },
            {
                "name": "Briess Golden Light DME 1 Lb",
                "price": 4.99,
                "catalog_id": 492
            },
        ]
    }
]


var YEASTS = [
    {
        "category": "Dry Beer Yeast",
        "yeasts": [
            {
                "name": "500 g Fermentis Safale S-04",
                "price": 79.99,
                "catalog_id": 6012
            },
            {
                "name": "500 g Fermentis Safale US-05",
                "price": 84.99,
                "catalog_id": 4612
            },
            {
                "name": "500 g Fermentis SafCider Yeast",
                "price": 59.99,
                "catalog_id": 6003
            },
        ]
    }
]
      
      
var RECIPE_DATA = [
    {
        id: 31,
        name: "my recipe ",
        notes: "some notes",
        brew_method: "All Grain",
        boil_time: 60,
        batch_size: "4.00",
        fermentable_selections: [
            {
                catalog_id: 496,
                milling_preference: "Unmilled"
            },
            {
                catalog_id: 1435,
                milling_preference: "Milled"
            }
        ],
        hop_selections: [
            {
                catalog_id: 110,
                weight: "4.00",
                minutes: 35,
                use: "Dry Hop"
            }
        ],
        yeast_selections: [
            {
                catalog_id: 6012
            }
        ]
    }
];


var API_BASE = "127.0.0.1:8000";

ko.observableArray.fn.countVisible = function(){
    return ko.computed(function(){
        var items = this();

        if (items === undefined || items.length === undefined){
            return 0;
        }

        var visibleCount = 0;

        for (var index = 0; index < items.length; index++){
            if (items[index]._destroy != true){
                visibleCount++;
            }
        }

        return visibleCount;
    }, this)();
};

function Fermentable(data) {
    var self = this;
    var options = data.options;
    self.fermentables_options = ko.computed(function(){
        return options;
    });
    self.catalog_id = ko.observable(data.catalog_id || "");
    self.name = ko.observable(data.name || "");
    self.milling_preference = ko.observable(data.milling_preference || "Milled");

    self.is_valid = ko.computed(function(){
        var valid = self.catalog_id() !== "" && self.catalog_id() !== "-";
        return valid
    });
}

function Hop(data) {
    var self = this;
    self.hops_options = ko.computed(function(){
        return data.options;
    });
    self.catalog_id = ko.observable(data.catalog_id || "");
    self.name = ko.observable(data.name || "");
    self.amount = ko.observable(data.amount || "");
    self.time = ko.observable(data.time || "");
    self.use = ko.observable(data.use || "Boil");

    self.is_valid = ko.computed(function(){
        var valid = self.amount() > 0 && self.catalog_id() !== "" && self.catalog_id() !== "-";
        return valid
    });
}

function Yeast(data){
    var self = this;
    var permanent_yeasts_options = data.yeasts_options;
    self.catalog_id = ko.observable(data.catalog_id || "");
    self.name = ko.observable(data.name || "-");
    self.current_filter = ko.observable("-Any-");
    self.yeast_groups_individual = ko.computed(function(){
        if (self.current_filter() !== "-Any-"){
            var options = _.filter(data.yeasts_options, function(option){
                return option.category === self.current_filter();
            });
            return options;
        } else{
                return permanent_yeasts_options;
            }
        }
    );
    self.yeast_categories = ko.observableArray();
    ko.computed(function(){
        var starter_list = ['-Any-'];
        var categories = _.pluck(permanent_yeasts_options, 'category');
        var final = starter_list.concat(categories);
        self.yeast_categories(final);
    });

    self.is_valid = ko.computed(function(){
        var valid = self.catalog_id() !== "" && self.catalog_id() !== "-";
        return valid
    });
}

function RecipeViewModel() {

    var self = this;

    // http://stackoverflow.com/questions/14220321/how-do-i-return-the-response-from-an-asynchronous-call
    self.get_data = function(url_ending){
        var URL = "http://%/api/&/".replace("&", url_ending).replace("%", API_BASE);
        var data =  $.ajax({
          dataType: "json",
          url: URL,
          async: false,
        });
        return data.responseJSON;
    }

    self.styles = [
        "--",
        "Standard American Beer",
        "International Lager",
        "Czech Lager",
        "Pale Malty European Lager",
        "Pale Bitter European Beer",
        "Amber Malty European Lager",
        "Amber Bitter European Lager",
        "Dark European Lager"
    ]
    self.styles_data = ko.observableArray();
    ko.computed(function(){
        var data = [];
        for (i = 0; i < self.styles.length; i++){
            var text = self.styles[i];
            if (text === "--"){
                data.push({value: "--", display: "--"});
            } else {
                var display_text = i.toString() + ". " + text;
                var this_entry = {value: text, display: display_text};
                data.push(this_entry);
            }
        }
        self.styles_data(data);
    });

    self.recipes = ko.observableArray();
    // self.recipes( self.get_data("recipes/all-recipes") );
    self.recipes(RECIPE_DATA);

    self.current_style = ko.observable("--");

    // defaults
    self.total_price = ko.observable(0.0); // TODO: this should not default if the recipe has items already...
    self.hops_uses = ko.observableArray(['Boil', 'Dry Hop']);
    self.weight_units = ko.observableArray(['oz', 'lb']);
    self.milling_preferences = ko.observableArray(['Milled', 'Unmilled']);
    self.brew_methods = ko.observableArray(['Extract', 'Mini-Mash', 'All Grain', 'Brew-in-a-bag']);

    // start of input fields
    self.name = ko.observable("");
    self.brew_method = ko.observable("Extract");
    self.batch_size = ko.observable("5");
    self.beer_style = ko.observable("Standard American Beer");
    self.boil_time = ko.observable("60");

    self.notes = ko.observable("");
    self.hops_options = HOPS;
    self.hops = ko.observableArray([new Hop({options: self.hops_options}), new Hop({options: self.hops_options})]);

    self.fermentables_options = FERMENTABLES;
    self.fermentables = ko.observableArray(
        [
            new Fermentable({options: self.fermentables_options}),
            new Fermentable({options: self.fermentables_options})
        ]
    );

    self.yeasts_options = YEASTS;
    self.yeasts = ko.observableArray([new Yeast({yeasts_options: self.yeasts_options})]);

    self.reset_form = function(){
        var x = 'finish this';
    }

    self.populate_recipe = function(data, event){
        var context = ko.contextFor(event.target);
        var index = context.$index();
        var recipe = self.recipes()[index];
        var attrs = ['name', 'brew_method', 'boil_time', 'batch_size', 'notes']
        for (i = 0; i < attrs.length; i++) {
            attr = attrs[i];
            self[attr](recipe[attr]);
        }

        fermentables_data = recipe.fermentable_selections;
        new_fermentables_data = [];
        for (i = 0; i < fermentables_data.length; i++) {
            var data_set = fermentables_data[i]
            data_set['options'] = self.fermentables_options;
            // takes {options: ..; catalog_id: ..; milling_preference}
            // based on the results of http://127.0.0.1:8000/api/recipes/all-recipes/
            var this_fermentable = new Fermentable(data_set);
            new_fermentables_data.push(this_fermentable);
        }
        self.fermentables(new_fermentables_data);
    }

    self.delete_recipe = function(data, event){
        var recipe_id = data.id;
        var URL = "http://%/api/recipes/delete/&/".replace("%", API_BASE).replace("&", recipe_id);
        $.ajax({
          url: URL,
          async: false,
        });

        self.recipes( self.get_data("recipes/all-recipes") );
    }

    self.valid_items = function(items){
        var final_items = _.filter(items, function(item){
            return item.is_valid();
        });
        return final_items;
    }

    self.valid_fermentables = ko.observableArray();
    ko.computed(function(){
        self.valid_fermentables(self.valid_items(self.fermentables()));
    });

    self.valid_hops = ko.observableArray();
    ko.computed(function(){
        self.valid_hops(self.valid_items(self.hops()));
    });

    self.valid_yeasts = ko.observableArray();
    ko.computed(function(){
        self.valid_yeasts(self.valid_items(self.yeasts()));
    });

    self.prices_hash = ko.computed(function(){
        var data = {};

        var strings = ['fermentables', 'hops', 'yeasts'];
        for (i = 0; i < strings.length; i++) {
            var string = strings[i];
            var attr = strings[i] + '_options';
            for (j = 0; j < self[attr].length; j++) {
                var groups = self[attr][j][string];
                for (k = 0; k < groups.length; k++) {
                    var catalog_id = groups[k].catalog_id.toString();
                    var current_price = groups[k].price;
                    data[catalog_id] = current_price;
                }
            }
        }
        return data;
    });

    self.current_price = ko.computed(function(){
        var total_price = 0;

        for (i = 0; i < self.valid_fermentables().length; i++){
            var item = self.valid_fermentables()[i];
            total_price = total_price + self.prices_hash()[item.catalog_id()];
        }

        for (i = 0; i < self.valid_hops().length; i++){
            var item = self.valid_hops()[i];
            total_price = total_price + self.prices_hash()[item.catalog_id()];
        }

        for (i = 0; i < self.valid_yeasts().length; i++){
            var item = self.valid_yeasts()[i];
            total_price = total_price + self.prices_hash()[item.catalog_id()];
        }

        return total_price.toFixed(2);
    });

    self.addFermentable = function(){
        self.fermentables.push(new Fermentable({options: self.fermentables_options}))
    }

    self.addYeast = function(){
        self.yeasts.push(new Yeast({yeasts_options: self.yeasts_options}));
    }

    self.addHop = function(){
        self.hops.push(new Hop({options: self.hops_options}));
    }

    self.removeFermentable = function(fermentable){
        self.fermentables.destroy(fermentable);
    }

    self.removeYeast = function(yeast){
        self.yeasts.destroy(yeast);
    }

    self.removeHop = function(hop){
        self.hops.destroy(hop);
    }

    // http://stackoverflow.com/questions/40501838/pass-string-parameters-into-click-binding-while-retaining-default-params-knockou
    self.removeItem = function(item, name){
        // not finished
        name.remove(function(hop){
            return hop.name === item.name;
        });
    }

    self.purify_fermentables = function(fermentables){
        var final_fermentables = [];
        for (i = 0; i < fermentables.length; i++){
            var item = fermentables[i];
            var object = {catalog_id: item.catalog_id, milling_preference: item.milling_preference};
            final_fermentables.push(object);
        }
        return final_fermentables;
    }

    self.purify_hops = function(hops){
        var final_hops = [];
        for (i = 0; i < hops.length; i++){
            var item = hops[i];
            var object = {catalog_id: item.catalog_id, amount: item.amount, time: item.time, use: item.use};
            final_hops.push(object);
        }
        return final_hops;
    }

    self.purify_yeasts = function(yeasts){
        var final_yeasts = [];
        for (i = 0; i < yeasts.length; i++){
            var item = yeasts[i];
            var object = {catalog_id: item.catalog_id};
            final_yeasts.push(object);
        }
        return final_yeasts;
    }


    self.prepareJSON = function(){
        // pure as in only the fields the server cares about
        var pure_fermentables = self.purify_fermentables(self.valid_fermentables());
        var pure_hops = self.purify_hops(self.valid_hops());
        var pure_yeasts = self.purify_yeasts(self.valid_yeasts());

        object = {
            fermentables: pure_fermentables,
            hops: pure_hops,
            yeasts: pure_yeasts,
            name: self.name(),
            brew_method: self.brew_method(),
            batch_size: self.batch_size(),
            beer_style: self.beer_style(),
            boil_time: self.boil_time(),
            notes: self.notes(),
        }
        return object;
    }

    self.saveRecipeData = function(){
        var recipe_data = ko.toJSON(self.prepareJSON());
        // alert("This is the data you're sending (universal Javascript object notation):\n\n" + recipe_data)
        $.ajax({
            url: "http://127.0.0.1:8000/api/recipes/receive-recipe/",
            headers: {
                "Content-Type": "application/json"
            },
            method: "POST",
            dataType: "json",
            async: false,
            data: recipe_data,
            success: function(data){
                console.log("Success! Saved the recipe");
            }
        });
        // http://stackoverflow.com/questions/951021/what-is-the-javascript-version-of-sleep
        // not working in browser...
        // await sleep(2000);
        self.recipes( self.get_data("recipes/all-recipes") );
    }

    self.my_to_json = function(object){
        return JSON.stringify(object, null, 4);
    }
}
ko.applyBindings(new RecipeViewModel());
        input[type="number"] {
            -moz-appearance: textfield;
        }
        input[type="number"]::-webkit-outer-spin-button,
        input[type="number"]::-webkit-inner-spin-button {
            -webkit-appearance: none;
            margin: 0;
        }

        input, select {
            border-radius: 3px;
        }

        #notes-input {
            width: 650px;
            height: 220px;
        }

        .label-text {
            /*font-weight: bold;*/
        }

       
<head>

    <style>


    </style>
    <script type='text/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.1/knockout-min.js'></script>
    <script type='text/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'></script>
    <script type='text/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js'></script>
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>

</head>

<body>
    <div class="container">
        <div class="row">
            <h3>My Recipes</h3>
            <ul data-bind="foreach: recipes">
                <li>
                    <!-- http://stackoverflow.com/questions/13054878/knockout-js-how-to-access-index-in-handler-function -->
                    <a data-bind="click: $root.populate_recipe">
                        <span data-bind="text: $data.name + '  id: ' + $data.id"></span>
                    </a>
                    <a data-bind="click: $root.delete_recipe">Delete Recipe</a>
                </li>
            </ul>
        </div>

        <div class="row">
            <br><br>
            <div class="col-md-2 col-md-offset-2">
                <span class="label-text">Recipe Name:</span>
            </div>

            <div class="col-md-2">
                <input type="text" data-bind="value: name" maxlength="250" class="recipeSetupText" />
            </div>

            <div class="col-md-4">
                <span class="label-text">Brew Method:</span>

                <select data-bind="options: brew_methods, value: brew_method"></select>
            </div>
        </div>

        <div class="row">

            <!-- http://stackoverflow.com/questions/8354975/how-can-i-limit-possible-inputs-in-a-html5-number-element -->

            <div class="col-md-2 col-md-offset-2">
                <span class="label-text" id="batch-size-label">Batch Size:</span>
            </div>

            <div class="col-md-2">
                <input type="number" data-bind="value: batch_size" style="width: 35px" /> <span class="unit">gallons</span>
            </div>

            <div class="col-md-4">
                <span class="label-text">Style:</span>
                <select data-bind="options: styles_data, optionsValue: 'value', optionsText: 'display', value: current_style"></select>
            </div>
        </div>

        <div class="row">
            <div class="col-md-4 col-md-offset-2">
                <span class="label-text" id="boil-time-label">Boil Time:</span>
                <input type="number" data-bind="value: boil_time" style="width: 60px" /> <span class="unit">(minutes)</span>
            </div>
        </div>

        <h2>Current price: <span data-bind="text: current_price"></span></h2>

        <div>
            <h2>Fermentables</h2>
            <div data-bind="foreach: fermentables">
                <select id="fermentable-variety-select" style="width:325px" data-bind="if: true, value: catalog_id">
                    <option value="-"> - </option>
                        <!-- ko foreach: fermentables_options -->
                        <optgroup data-bind="attr: {label: category}">
                            <!-- ko foreach: fermentables -->
                                <option data-bind="value: $data.catalog_id, text: $data.name + ' -- ' + $data.price.toFixed(2)"></option>
                            <!-- /ko -->
                        </optgroup>
                    <!-- /ko -->
                </select>
                <label>Milling preference: </label>
                <select data-bind="options: $root.milling_preferences, value: milling_preference"></select>
                <a href="#" data-bind="click: $root.removeFermentable, visible: $root.fermentables.countVisible() > 1">
                    Delete
                </a>
                <br><br>
            </div>
            <input data-bind="click: addFermentable" type="button" value="Add Fermentable"/>
        </div>

        <div class="row">
            <h2 class="">Yeast</h2>
            <div data-bind="foreach: yeasts">
                <span>Yeast Brand Filter:</span>
                <select data-bind="options: yeast_categories, value: current_filter" id="yeast-brand-select">
                </select>
                <br/>
                <span>Yeast Variety:</span>
                <select id="yeast-variety-select" style="width:325px" data-bind="if: true, value: catalog_id">
                    <option value="-"> - </option>
                        <!-- ko foreach: yeast_groups_individual -->
                        <optgroup data-bind="attr: {label: category}">
                            <!-- ko foreach: yeasts -->
                                <option data-bind="value: $data.catalog_id, text: $data.name + ' -- ' + $data.price.toFixed(2)"></option>
                            <!-- /ko -->
                        </optgroup>
                    <!-- /ko -->
                </select>
                <a href="#" data-bind="click: $root.removeYeast, visible: $root.yeasts.countVisible() > 1">Delete</a>
                <br><br>
            </div>
            <br>
            <input data-bind="click: addYeast" type="button" value="Add Yeast"/>
        </div>

        <div class="row">
            <h2 class="">Hops</h2>
            <div data-bind='foreach: hops'>
            <select id="hops-variety-select" style="width:325px" data-bind="if: true, value: catalog_id">
                <option value="-"> - </option>
                    <!-- ko foreach: hops_options -->
                    <optgroup data-bind="attr: {label: category}">
                        <!-- ko foreach: hops -->
                            <option data-bind="value: $data.catalog_id, text: $data.name + ' -- ' + $data.price.toFixed(2)"></option>
                        <!-- /ko -->
                    </optgroup>
                <!-- /ko -->
            </select>
            <label>Amount:</label>
            <input type="number" data-bind="value: amount" maxlength="6"> oz

            Time: <input type="text" data-bind="value: time" >
            Min.
            Use:  <select data-bind="options: $root.hops_uses, value: use"></select>

            <a href="#" data-bind="click: function() { $root.removeItem($data, $root.hops) }, visible: $root.hops.countVisible() > 1">Delete</a>

            <br><br>
        </div>

        <br>

        <input data-bind="click: addHop" type="button" value="Add Hop" />
    </div>

    <br>

    <textarea data-bind="value: notes" id="notes-input" placeholder="Write any extra notes here..." style="resize: both;"></textarea>

    <p>
        <button data-bind="click: saveRecipeData">Save recipe</button>
    </p>

    </div>

    <script src='index.js' type='text/javascript'></script>
</body>
halfer
  • 19,824
  • 17
  • 99
  • 186
Michael Best
  • 16,623
  • 1
  • 37
  • 70