From cc398bd9d87234f4b53f4c35afc4eb33f6436cd8 Mon Sep 17 00:00:00 2001 From: Dan Elbert Date: Thu, 20 Oct 2016 15:48:33 -0500 Subject: [PATCH] Added tag editing --- app/assets/javascripts/application.js | 1 + app/assets/javascripts/recipe_editor.js | 854 +++++++++--------- app/assets/stylesheets/application.scss | 1 + app/controllers/recipes_controller.rb | 4 +- app/controllers/tags_controller.rb | 16 + app/models/concerns/tokenized_like.rb | 6 + app/models/recipe.rb | 14 + app/models/tag.rb | 25 + app/views/recipes/_editor.html.erb | 5 + app/views/tags/search.json.jbuilder | 6 + config/routes.rb | 9 + db/migrate/20161014173138_create_tags.rb | 3 +- db/schema.rb | 17 +- spec/factories/tags.rb | 2 +- spec/models/recipe_spec.rb | 49 + .../assets/javascripts/bootstrap-tagsinput.js | 682 ++++++++++++++ .../bootstrap-tagsinput-typeahead.css | 54 ++ .../stylesheets/bootstrap-tagsinput.css | 60 ++ 18 files changed, 1392 insertions(+), 416 deletions(-) create mode 100644 app/controllers/tags_controller.rb create mode 100644 app/views/tags/search.json.jbuilder create mode 100644 vendor/assets/javascripts/bootstrap-tagsinput.js create mode 100644 vendor/assets/stylesheets/bootstrap-tagsinput-typeahead.css create mode 100644 vendor/assets/stylesheets/bootstrap-tagsinput.css diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index ed6ea4a..3ae0d91 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -15,6 +15,7 @@ //= require turbolinks //= require bootstrap-sprockets //= require bootstrap-datepicker +//= require bootstrap-tagsinput //= require cocoon //= require typeahead //= require autosize diff --git a/app/assets/javascripts/recipe_editor.js b/app/assets/javascripts/recipe_editor.js index 116af43..ea30cc8 100644 --- a/app/assets/javascripts/recipe_editor.js +++ b/app/assets/javascripts/recipe_editor.js @@ -1,458 +1,490 @@ (function($) { - var ingredientSearchEngine = new Bloodhound({ - initialize: false, - datumTokenizer: function(datum) { - return Bloodhound.tokenizers.whitespace(datum.name); - }, - queryTokenizer: Bloodhound.tokenizers.whitespace, - identify: function(datum) { return datum.id; }, - sorter: function(a, b) { - if (a.name < b.name) { - return -1; - } else if (b.name < a.name) { - return 1; - } else { - return 0; - } - }, - prefetch: { - url: '/ingredients/prefetch.json', - cache: false - }, - remote: { - url: '/ingredients/search.json?query=%QUERY', - wildcard: '%QUERY' - } - }); - - function reorder($container) { - $container.find("div.nested-fields").each(function(idx, editor) { - var $editor = $(editor); - $editor.find('input.sort_order').val(idx + 1).trigger("changed"); - }) - } - - function initializeStepEditor($container) { - // $container is either an element that contains many editors, or a single editor. - var $editors = $container.find("textarea.step").closest(".step-editor"); - - $editors.each(function(idx, elem) { - var $editor = $(elem); - var $step = $editor.find("textarea.step"); - autosize($step); - - setTimeout(function() { autosize.update($step); }, 250); + var ingredientSearchEngine = new Bloodhound({ + initialize: false, + datumTokenizer: function(datum) { + return Bloodhound.tokenizers.whitespace(datum.name); + }, + queryTokenizer: Bloodhound.tokenizers.whitespace, + identify: function(datum) { return datum.id; }, + sorter: function(a, b) { + if (a.name < b.name) { + return -1; + } else if (b.name < a.name) { + return 1; + } else { + return 0; + } + }, + prefetch: { + url: '/ingredients/prefetch.json', + cache: false + }, + remote: { + url: '/ingredients/search.json?query=%QUERY', + wildcard: '%QUERY' + } }); - } - function initializeIngredientEditor($container, ingredientSearchEngine) { - // $container is either an element that contains many editors, or a single editor. - var $editors = $container.find(".ingredient-typeahead").closest(".nested-fields"); + // var tagSearchEngine = new Bloodhound({ + // initialize: false, + // datumTokenizer: function(datum) { + // return Bloodhound.tokenizers.whitespace(datum.name); + // }, + // queryTokenizer: Bloodhound.tokenizers.whitespace, + // identify: function(datum) { return datum.id; }, + // sorter: function(a, b) { + // if (a.name < b.name) { + // return -1; + // } else if (b.name < a.name) { + // return 1; + // } else { + // return 0; + // } + // }, + // prefetch: { + // url: '/tags/prefetch.json', + // cache: false + // }, + // remote: { + // url: '/tags/search.json?query=%QUERY', + // wildcard: '%QUERY' + // } + // }); - $editors.each(function(idx, elem) { - var $editor = $(elem); - var $ingredientId = $editor.find("input.ingredient_id"); - var $group = $editor.find("div.typeahead-group"); + function reorder($container) { + $container.find("div.nested-fields").each(function(idx, editor) { + var $editor = $(editor); + $editor.find('input.sort_order').val(idx + 1).trigger("changed"); + }) + } - $editor.find(".ingredient-typeahead").typeahead({}, - { - name: 'ingredients', - source: ingredientSearchEngine, - display: function(datum) { - return datum.name; - } + function initializeStepEditor($container) { + // $container is either an element that contains many editors, or a single editor. + var $editors = $container.find("textarea.step").closest(".step-editor"); + + $editors.each(function(idx, elem) { + var $editor = $(elem); + var $step = $editor.find("textarea.step"); + autosize($step); + + setTimeout(function() { autosize.update($step); }, 250); + }); + } + + function initializeIngredientEditor($container, ingredientSearchEngine) { + // $container is either an element that contains many editors, or a single editor. + var $editors = $container.find(".ingredient-typeahead").closest(".nested-fields"); + + $editors.each(function(idx, elem) { + var $editor = $(elem); + var $ingredientId = $editor.find("input.ingredient_id"); + var $group = $editor.find("div.typeahead-group"); + + $editor.find(".ingredient-typeahead").typeahead({}, + { + name: 'ingredients', + source: ingredientSearchEngine, + display: function(datum) { + return datum.name; + } + }); + + if ($ingredientId.val().length) { + $group.addClass("has-success"); + } + }); + } + + function ingredientItemPicked($typeahead, datum) { + var $container = $typeahead.closest(".nested-fields"); + var $ingredientId = $container.find("input.ingredient_id"); + var $group = $container.find("div.typeahead-group"); + + $ingredientId.val(datum.id); + $typeahead.typeahead('val', datum.name); + $group.addClass("has-success"); + } + + function ingredientNameChange($typeahead, ingredientSearchEngine) { + var $container = $typeahead.closest(".nested-fields"); + var $ingredientId = $container.find("input.ingredient_id"); + var $group = $container.find("div.typeahead-group"); + + var id = $ingredientId.val(); + var value = $typeahead.typeahead('val'); + + if (id && id.length) { + var found = ingredientSearchEngine.get([id]); + if (found && found[0] && found[0].name != value) { + // User has chosen something custom + $ingredientId.val(''); + + $group.removeClass("has-success"); + } + } + } + + function addIngredient(item) { + $("#ingredient-list").one("cocoon:before-insert", function(e, $container) { + var $ingredientId = $container.find("input.ingredient_id"); + var $name = $container.find("input.ingredient-typeahead"); + var $quantity = $container.find("input.quantity"); + var $units = $container.find("input.units"); + var $preparation = $container.find("input.preparation"); + + $name.val(item.name); + $ingredientId.val(item.ingredient_id); + $units.val(item.units); + $quantity.val(item.quantity); + $preparation.val(item.preparation); }); - if ($ingredientId.val().length) { - $group.addClass("has-success"); - } - }); - } - - function ingredientItemPicked($typeahead, datum) { - var $container = $typeahead.closest(".nested-fields"); - var $ingredientId = $container.find("input.ingredient_id"); - var $group = $container.find("div.typeahead-group"); - - $ingredientId.val(datum.id); - $typeahead.typeahead('val', datum.name); - $group.addClass("has-success"); - } - - function ingredientNameChange($typeahead, ingredientSearchEngine) { - var $container = $typeahead.closest(".nested-fields"); - var $ingredientId = $container.find("input.ingredient_id"); - var $group = $container.find("div.typeahead-group"); - - var id = $ingredientId.val(); - var value = $typeahead.typeahead('val'); - - if (id && id.length) { - var found = ingredientSearchEngine.get([id]); - if (found && found[0] && found[0].name != value) { - // User has chosen something custom - $ingredientId.val(''); - - $group.removeClass("has-success"); - } - } - } - - function addIngredient(item) { - $("#ingredient-list").one("cocoon:before-insert", function(e, $container) { - var $ingredientId = $container.find("input.ingredient_id"); - var $name = $container.find("input.ingredient-typeahead"); - var $quantity = $container.find("input.quantity"); - var $units = $container.find("input.units"); - var $preparation = $container.find("input.preparation"); - - $name.val(item.name); - $ingredientId.val(item.ingredient_id); - $units.val(item.units); - $quantity.val(item.quantity); - $preparation.val(item.preparation); - }); - - $("#addIngredientButton").trigger("click"); - } - - function getIngredients() { - var data = []; - $("#ingredient-list .ingredient-editor").each(function() { - var $container = $(this); - - var $ingredientId = $container.find("input.ingredient_id"); - var $name = $container.find("input.ingredient-typeahead.tt-input"); - var $quantity = $container.find("input.quantity"); - var $units = $container.find("input.units"); - var $preparation = $container.find("input.preparation"); - - data.push({ingredient_id: $ingredientId.val(), name: $name.typeahead("val"), quantity: $quantity.val(), units: $units.val(), preparation: $preparation.val()}); - }); - - return data; - } - - function addStep(step) { - $("#step-list").one("cocoon:before-insert", function(e, $container) { - var $step = $container.find("textarea.step"); - $step.val(step); - }); - - $("#addStepButton").trigger("click"); - } - - function getSteps() { - var data = []; - $("#step-list .step-editor").each(function() { - var $container = $(this); - - var $step = $container.find("textarea.step"); - - data.push($step.val()); - }); - - return data; - } - - $(document).on("turbolinks:load", function() { - - var $ingredientList = $("#ingredient-list"); - var $stepList = $("#step-list"); - - if ($ingredientList.length) { - ingredientSearchEngine.initialize(false); + $("#addIngredientButton").trigger("click"); } - initializeStepEditor($stepList); + function getIngredients() { + var data = []; + $("#ingredient-list .ingredient-editor").each(function() { + var $container = $(this); - $stepList - .on("cocoon:after-insert", function(e, item) { - reorder($(this)); - initializeStepEditor(item); - }) - .on("cocoon:after-remove", function(e, item) { - if (item.find(".remove-button.existing").length) { - item.detach().appendTo("#deleted_steps"); - } - reorder($(this)); - }) - .on('changed', 'input.sort_order', function() { - var $this = $(this); - var $span = $this.closest(".nested-fields").find(".sort-order-display"); - $span.html($this.val()); - }); + var $ingredientId = $container.find("input.ingredient_id"); + var $name = $container.find("input.ingredient-typeahead.tt-input"); + var $quantity = $container.find("input.quantity"); + var $units = $container.find("input.units"); + var $preparation = $container.find("input.preparation"); + data.push({ingredient_id: $ingredientId.val(), name: $name.typeahead("val"), quantity: $quantity.val(), units: $units.val(), preparation: $preparation.val()}); + }); - initializeIngredientEditor($ingredientList, ingredientSearchEngine); + return data; + } - $ingredientList - .on("cocoon:after-insert", function(e, item) { - reorder($ingredientList); - initializeIngredientEditor(item, ingredientSearchEngine); - }) - .on("cocoon:after-remove", function(e, item) { - if (item.find(".remove-button.existing").length) { - item.detach().appendTo("#deleted_ingredients"); - } - reorder($ingredientList); - }) - .on("typeahead:change", function(evt, value) { - ingredientNameChange($(evt.target), ingredientSearchEngine); - }) - .on("typeahead:select", function(evt, value) { - ingredientItemPicked($(evt.target), value); - }) - .on("typeahead:autocomplete", function(evt, value) { - ingredientItemPicked($(evt.target), value); - }) - .on("click", "button.ingredient_convert_btn", function(evt) { + function addStep(step) { + $("#step-list").one("cocoon:before-insert", function(e, $container) { + var $step = $container.find("textarea.step"); + $step.val(step); + }); - }); + $("#addStepButton").trigger("click"); + } - $('#convert_modal') - .on('show.bs.modal', function (event) { - var $button = $(event.relatedTarget); - var $modal = $(this); + function getSteps() { + var data = []; + $("#step-list .step-editor").each(function() { + var $container = $(this); - var $editor = $button.closest(".ingredient-editor"); + var $step = $container.find("textarea.step"); - $modal.data('ingredient-editor', $editor); + data.push($step.val()); + }); - var $quantity = $editor.find("input.quantity"); - var $units = $editor.find("input.units"); - var $ingredientId = $editor.find("input.ingredient_id"); + return data; + } - var $modalQuantity = $modal.find("input.quantity"); - var $modalUnits = $modal.find("input.units"); - var $modalIngredientId = $modal.find("input.ingredient_id"); + $(document).on("turbolinks:load", function() { - $modalQuantity.val($quantity.val()); - $modalUnits.val($units.val()); - $modalIngredientId.val($ingredientId.val()); - }) - .on("ajax:success", "form", function(evt, data, status, xhr) { - var $modal = $("#convert_modal"); - var $editor = $modal.data('ingredient-editor'); - - if (data.success) { - var $quantity = $editor.find("input.quantity"); - var $units = $editor.find("input.units"); - - var $modalOutUnits = $modal.find("input.output_units"); - - $quantity.val(data.output_quantity); - if ($modalOutUnits.val().length) { - $units.val($modalOutUnits.val()); - } - - $modal.modal('hide'); - } else { + var $ingredientList = $("#ingredient-list"); + var $stepList = $("#step-list"); + var $tagInput = $("select.tag_names"); + if ($ingredientList.length) { + ingredientSearchEngine.initialize(false); } - $("#modal_form_container").replaceWith($(data.form_html)); - }); + $tagInput.tagsinput({ + trimValue: true, + confirmKeys: [9, 13, 32, 44] // tab, enter, space, comma + }); - var $bulkIngredientsModal = $("#bulk_ingredients_modal"); - var $ingredientBulkInput = $("#ingredient_bulk_input"); - var $ingredientBulkList = $("#ingredient_bulk_parsed_list"); - autosize($ingredientBulkInput); + initializeStepEditor($stepList); - var parseBulkIngredients = function() { - var data = $ingredientBulkInput.val(); - $ingredientBulkList.empty(); + $stepList + .on("cocoon:after-insert", function(e, item) { + reorder($(this)); + initializeStepEditor(item); + }) + .on("cocoon:after-remove", function(e, item) { + if (item.find(".remove-button.existing").length) { + item.detach().appendTo("#deleted_steps"); + } + reorder($(this)); + }) + .on('changed', 'input.sort_order', function() { + var $this = $(this); + var $span = $this.closest(".nested-fields").find(".sort-order-display"); + $span.html($this.val()); + }); - var parsed = []; - var x; - var lines = data.replace("\r", "").split("\n"); + initializeIngredientEditor($ingredientList, ingredientSearchEngine); - var regex = /^(?:([\d\/.]+(?:\s+[\d\/]+)?)\s+)?(?:([\w-]+)(?:\s+of)?\s+)?([^,]*)(?:,\s*(.*))?$/i; + $ingredientList + .on("cocoon:after-insert", function(e, item) { + reorder($ingredientList); + initializeIngredientEditor(item, ingredientSearchEngine); + }) + .on("cocoon:after-remove", function(e, item) { + if (item.find(".remove-button.existing").length) { + item.detach().appendTo("#deleted_ingredients"); + } + reorder($ingredientList); + }) + .on("typeahead:change", function(evt, value) { + ingredientNameChange($(evt.target), ingredientSearchEngine); + }) + .on("typeahead:select", function(evt, value) { + ingredientItemPicked($(evt.target), value); + }) + .on("typeahead:autocomplete", function(evt, value) { + ingredientItemPicked($(evt.target), value); + }) + .on("click", "button.ingredient_convert_btn", function(evt) { - var magicFunc = function(str) { - if (str == "-") { - return ""; - } else { - return str; - } - }; + }); - for (x = 0; x < lines.length; x++) { - var line = lines[x].trim(); - if (line.length == 0) { continue; } + $('#convert_modal') + .on('show.bs.modal', function (event) { + var $button = $(event.relatedTarget); + var $modal = $(this); - var barIndex = line.lastIndexOf("|"); - var afterBar = null; + var $editor = $button.closest(".ingredient-editor"); - if (barIndex >= 0) { - afterBar = line.slice(barIndex + 1); - line = line.slice(0, barIndex); - } + $modal.data('ingredient-editor', $editor); - var match = line.match(regex); + var $quantity = $editor.find("input.quantity"); + var $units = $editor.find("input.units"); + var $ingredientId = $editor.find("input.ingredient_id"); - if (match) { - item = {quantity: magicFunc(match[1]), units: magicFunc(match[2]), name: magicFunc(match[3]), preparation: magicFunc(match[4])}; - if (afterBar) { - item.name = item.name + ", " + item.preparation; - item.preparation = afterBar; - } - parsed.push(item); - } else { - parsed.push(null); - } - } + var $modalQuantity = $modal.find("input.quantity"); + var $modalUnits = $modal.find("input.units"); + var $modalIngredientId = $modal.find("input.ingredient_id"); - $bulkIngredientsModal.data("bulkData", parsed); + $modalQuantity.val($quantity.val()); + $modalUnits.val($units.val()); + $modalIngredientId.val($ingredientId.val()); + }) + .on("ajax:success", "form", function(evt, data, status, xhr) { + var $modal = $("#convert_modal"); + var $editor = $modal.data('ingredient-editor'); - for (x = 0; x < parsed.length; x++) { - var item = parsed[x]; - if (item != null) { - $ingredientBulkList.append( - $("") - .append($("").addClass("quantity").text(item.quantity)) - .append($("").addClass("units").text(item.units)) - .append($("").addClass("name").text(item.name)) - .append($("").addClass("preparation").text(item.preparation)) - ); - } else { - $ingredientBulkList.append( - $("") - .append($("").attr("colspan", "4").text("")) - ); - } - } - }; + if (data.success) { + var $quantity = $editor.find("input.quantity"); + var $units = $editor.find("input.units"); - $bulkIngredientsModal - .on('show.bs.modal', function (event) { - var data = getIngredients(); - var x; - var text = []; + var $modalOutUnits = $modal.find("input.output_units"); - for (x = 0; x < data.length; x++) { - var item = data[x]; + $quantity.val(data.output_quantity); + if ($modalOutUnits.val().length) { + $units.val($modalOutUnits.val()); + } - text.push( - item.quantity + " " + - (item.units || "-") + " " + - item.name + - (item.preparation ? (", " + item.preparation) : "") - ); - } + $modal.modal('hide'); + } else { - $ingredientBulkInput.val(text.join("\n")); + } - setTimeout(function() { - parseBulkIngredients(); - autosize.update($ingredientBulkInput); - }, 250); - }); + $("#modal_form_container").replaceWith($(data.form_html)); + }); + + var $bulkIngredientsModal = $("#bulk_ingredients_modal"); + var $ingredientBulkInput = $("#ingredient_bulk_input"); + var $ingredientBulkList = $("#ingredient_bulk_parsed_list"); + autosize($ingredientBulkInput); + + var parseBulkIngredients = function() { + var data = $ingredientBulkInput.val(); + $ingredientBulkList.empty(); + + var parsed = []; + var x; + + var lines = data.replace("\r", "").split("\n"); + + var regex = /^(?:([\d\/.]+(?:\s+[\d\/]+)?)\s+)?(?:([\w-]+)(?:\s+of)?\s+)?([^,]*)(?:,\s*(.*))?$/i; + + var magicFunc = function(str) { + if (str == "-") { + return ""; + } else { + return str; + } + }; + + for (x = 0; x < lines.length; x++) { + var line = lines[x].trim(); + if (line.length == 0) { continue; } + + var barIndex = line.lastIndexOf("|"); + var afterBar = null; + + if (barIndex >= 0) { + afterBar = line.slice(barIndex + 1); + line = line.slice(0, barIndex); + } + + var match = line.match(regex); + + if (match) { + item = {quantity: magicFunc(match[1]), units: magicFunc(match[2]), name: magicFunc(match[3]), preparation: magicFunc(match[4])}; + if (afterBar) { + item.name = item.name + ", " + item.preparation; + item.preparation = afterBar; + } + parsed.push(item); + } else { + parsed.push(null); + } + } + + $bulkIngredientsModal.data("bulkData", parsed); + + for (x = 0; x < parsed.length; x++) { + var item = parsed[x]; + if (item != null) { + $ingredientBulkList.append( + $("") + .append($("").addClass("quantity").text(item.quantity)) + .append($("").addClass("units").text(item.units)) + .append($("").addClass("name").text(item.name)) + .append($("").addClass("preparation").text(item.preparation)) + ); + } else { + $ingredientBulkList.append( + $("") + .append($("").attr("colspan", "4").text("")) + ); + } + } + }; + + $bulkIngredientsModal + .on('show.bs.modal', function (event) { + var data = getIngredients(); + var x; + var text = []; + + for (x = 0; x < data.length; x++) { + var item = data[x]; + + text.push( + item.quantity + " " + + (item.units || "-") + " " + + item.name + + (item.preparation ? (", " + item.preparation) : "") + ); + } + + $ingredientBulkInput.val(text.join("\n")); + + setTimeout(function() { + parseBulkIngredients(); + autosize.update($ingredientBulkInput); + }, 250); + }); + + $ingredientBulkInput.on('keyup', function() { + parseBulkIngredients(); + }); + + $("#bulkIngredientAddSubmit").on("click", function() { + var parsed = $bulkIngredientsModal.data("bulkData"); + var x; + + $("#ingredient-list").find(".remove-button").trigger("click"); + + if (parsed && parsed.length) { + for (x = 0; x < parsed.length; x++) { + var item = parsed[x]; + if (item) { + addIngredient(item) + } + } + } + + $bulkIngredientsModal.modal('hide') + }); + + + // =========================================== + // =========================================== + + + var $bulkStepsModal = $("#bulk_steps_modal"); + var $stepBulkInput = $("#step_bulk_input"); + var $stepBulkList = $("#step_bulk_parsed_list"); + autosize($stepBulkInput); + + var parseBulkSteps = function() { + var data = $stepBulkInput.val(); + $stepBulkList.empty(); + + var parsed = []; + var x; + + var lines = data.replace("\r", "").split("\n\n"); + + for (x = 0; x < lines.length; x++) { + var line = lines[x].trim().replace(/^\d+\./, "").trim(); + if (line.length == 0) { continue; } + + parsed.push(line); + } + + $bulkStepsModal.data("bulkData", parsed); + + for (x = 0; x < parsed.length; x++) { + var item = parsed[x]; + if (item != null) { + $stepBulkList.append( + $("") + .append($("").addClass("step").text(x + 1)) + .append($("").addClass("direction").text(item)) + ); + } else { + $stepBulkList.append( + $("") + .append($("").attr("colspan", "2").text("")) + ); + } + } + }; + + $bulkStepsModal + .on('show.bs.modal', function (event) { + var data = getSteps(); + $stepBulkInput.val(data.join("\n\n")); + $stepBulkList.empty(); + + setTimeout(function() { + parseBulkSteps(); + autosize.update($stepBulkInput); + }, 250); + }); + + $stepBulkInput.on('keyup', function() { + parseBulkSteps(); + }); + + $("#bulkStepAddSubmit").on("click", function() { + var parsed = $bulkStepsModal.data("bulkData"); + var x; + + $("#step-list").find(".remove-button").trigger("click"); + + if (parsed && parsed.length) { + for (x = 0; x < parsed.length; x++) { + var item = parsed[x]; + if (item) { + addStep(item); + } + } + } + + $bulkStepsModal.modal('hide') + }); - $ingredientBulkInput.on('keyup', function() { - parseBulkIngredients(); }); - $("#bulkIngredientAddSubmit").on("click", function() { - var parsed = $bulkIngredientsModal.data("bulkData"); - var x; - - $("#ingredient-list").find(".remove-button").trigger("click"); - - if (parsed && parsed.length) { - for (x = 0; x < parsed.length; x++) { - var item = parsed[x]; - if (item) { - addIngredient(item) - } - } - } - - $bulkIngredientsModal.modal('hide') - }); - - - // =========================================== - // =========================================== - - - var $bulkStepsModal = $("#bulk_steps_modal"); - var $stepBulkInput = $("#step_bulk_input"); - var $stepBulkList = $("#step_bulk_parsed_list"); - autosize($stepBulkInput); - - var parseBulkSteps = function() { - var data = $stepBulkInput.val(); - $stepBulkList.empty(); - - var parsed = []; - var x; - - var lines = data.replace("\r", "").split("\n\n"); - - for (x = 0; x < lines.length; x++) { - var line = lines[x].trim().replace(/^\d+\./, "").trim(); - if (line.length == 0) { continue; } - - parsed.push(line); - } - - $bulkStepsModal.data("bulkData", parsed); - - for (x = 0; x < parsed.length; x++) { - var item = parsed[x]; - if (item != null) { - $stepBulkList.append( - $("") - .append($("").addClass("step").text(x + 1)) - .append($("").addClass("direction").text(item)) - ); - } else { - $stepBulkList.append( - $("") - .append($("").attr("colspan", "2").text("")) - ); - } - } - }; - - $bulkStepsModal - .on('show.bs.modal', function (event) { - var data = getSteps(); - $stepBulkInput.val(data.join("\n\n")); - $stepBulkList.empty(); - - setTimeout(function() { - parseBulkSteps(); - autosize.update($stepBulkInput); - }, 250); - }); - - $stepBulkInput.on('keyup', function() { - parseBulkSteps(); - }); - - $("#bulkStepAddSubmit").on("click", function() { - var parsed = $bulkStepsModal.data("bulkData"); - var x; - - $("#step-list").find(".remove-button").trigger("click"); - - if (parsed && parsed.length) { - for (x = 0; x < parsed.length; x++) { - var item = parsed[x]; - if (item) { - addStep(item); - } - } - } - - $bulkStepsModal.modal('hide') - }); - - }); - })(jQuery); diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index dfe4a43..7beb602 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -13,6 +13,7 @@ *= require flash_messages *= require chosen *= require bootstrap-datepicker3 + *= require bootstrap-tagsinput *= require font_references */ diff --git a/app/controllers/recipes_controller.rb b/app/controllers/recipes_controller.rb index fe4498d..1eabc62 100644 --- a/app/controllers/recipes_controller.rb +++ b/app/controllers/recipes_controller.rb @@ -9,7 +9,7 @@ class RecipesController < ApplicationController @criteria = ViewModels::RecipeCriteria.new(params[:criteria]) @criteria.page = params[:page] @criteria.per = params[:per] - @recipes = Recipe.for_criteria(@criteria) + @recipes = Recipe.for_criteria(@criteria).includes(:tags) end # GET /recipes/1 @@ -106,6 +106,6 @@ class RecipesController < ApplicationController # Never trust parameters from the scary internet, only allow the white list through. def recipe_params - params.require(:recipe).permit(:name, :description, :source, :yields, :total_time, :active_time, recipe_ingredients_attributes: [:name, :ingredient_id, :quantity, :units, :preparation, :sort_order, :id, :_destroy], recipe_steps_attributes: [:step, :sort_order, :id, :_destroy]) + params.require(:recipe).permit(:name, :description, :source, :yields, :total_time, :active_time, tag_names: [], recipe_ingredients_attributes: [:name, :ingredient_id, :quantity, :units, :preparation, :sort_order, :id, :_destroy], recipe_steps_attributes: [:step, :sort_order, :id, :_destroy]) end end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb new file mode 100644 index 0000000..4e7850c --- /dev/null +++ b/app/controllers/tags_controller.rb @@ -0,0 +1,16 @@ +class TagsController < ApplicationController + + def index + + end + + def prefetch + @tags = Tag.all.order(:name) + render :search + end + + def search + @tags = Tag.search(params[:query]).order(:name) + end + +end \ No newline at end of file diff --git a/app/models/concerns/tokenized_like.rb b/app/models/concerns/tokenized_like.rb index b3a7c95..12c04df 100644 --- a/app/models/concerns/tokenized_like.rb +++ b/app/models/concerns/tokenized_like.rb @@ -22,5 +22,11 @@ module TokenizedLike query end + def matches_token(attribute, token) + table = self.arel_table + query = self.all + query.where(table[attribute.to_sym].matches("#{token}%")) + end + end end \ No newline at end of file diff --git a/app/models/recipe.rb b/app/models/recipe.rb index 91635e3..72501f1 100644 --- a/app/models/recipe.rb +++ b/app/models/recipe.rb @@ -48,6 +48,20 @@ class Recipe < ApplicationRecord end end + def tag_names + self.tags.map { |t| t.name } + end + + def tag_names=(names) + names = Array.wrap(names).map { |n| n.to_s }.select { |n| n.length > 0 } + existing_tags = Tag.by_name(names) + new_tags = names.select { |n| existing_tags.none? { |t| t.is?(n) } } + self.tags = existing_tags + new_tags.each do |n| + self.tags << Tag.new(name: n) + end + end + def nutrition_data(recalculate = false) if recalculate || @nutrition_data.nil? @nutrition_data = calculate_nutrition_data diff --git a/app/models/tag.rb b/app/models/tag.rb index acb4e13..dcfe6ca 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,5 +1,30 @@ class Tag < ApplicationRecord + include TokenizedLike + + scope :by_name, ->(names) { where(lowercase_name: Array.wrap(names).map { |n| n.downcase }) } validates :name, presence: true, length: {maximum: 250}, uniqueness: { case_sensitive: false } + before_save :set_lowercase_name + + def is?(tag_name) + tag_name.casecmp(self.name) == 0 + end + + def self.search(query) + token = query.to_s.strip + + if token.empty? + Tag.none + else + Tag.matches_token(:name, token) + end + end + + private + + def set_lowercase_name + self.lowercase_name = self.name.downcase + end + end diff --git a/app/views/recipes/_editor.html.erb b/app/views/recipes/_editor.html.erb index b200eda..b8be741 100644 --- a/app/views/recipes/_editor.html.erb +++ b/app/views/recipes/_editor.html.erb @@ -23,6 +23,11 @@ <%= f.text_area :description, class: 'form-control' %> +
+ <%= f.label :tag_names, class: "control-label" %> + <%= f.select :tag_names, options_for_select(f.object.tag_names), {}, multiple: true, class: 'form-control tag_names' %> +
+
diff --git a/app/views/tags/search.json.jbuilder b/app/views/tags/search.json.jbuilder new file mode 100644 index 0000000..8f4a680 --- /dev/null +++ b/app/views/tags/search.json.jbuilder @@ -0,0 +1,6 @@ + +json.array! @tags do |t| + + json.extract! t, :id, :name + +end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 2747b49..1bee66c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,6 +21,15 @@ Rails.application.routes.draw do match '/ingredients(/:id)/select_ndbn' => 'ingredients#select_ndbn', via: [:post, :patch, :put] + resources :tags, only: [:index] do + collection do + constraints format: 'json' do + get :search + get :prefetch + end + end + end + resource :user, only: [:new, :create, :edit, :update] get '/login' => 'users#login', as: :login diff --git a/db/migrate/20161014173138_create_tags.rb b/db/migrate/20161014173138_create_tags.rb index 5ba574c..f0c587b 100644 --- a/db/migrate/20161014173138_create_tags.rb +++ b/db/migrate/20161014173138_create_tags.rb @@ -1,7 +1,8 @@ class CreateTags < ActiveRecord::Migration[5.0] def change create_table :tags do |t| - t.string :name, index: {unique: true} + t.string :name + t.string :lowercase_name, index: {unique: true} t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index c577f83..6ef5d60 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20161014161544) do +ActiveRecord::Schema.define(version: 20161014173138) do create_table "ingredient_units", force: :cascade do |t| t.integer "ingredient_id", null: false @@ -110,6 +110,21 @@ ActiveRecord::Schema.define(version: 20161014161544) do t.float "rating" end + create_table "recipes_tags", id: false, force: :cascade do |t| + t.integer "recipe_id" + t.integer "tag_id" + t.index ["recipe_id"], name: "index_recipes_tags_on_recipe_id" + t.index ["tag_id"], name: "index_recipes_tags_on_tag_id" + end + + create_table "tags", force: :cascade do |t| + t.string "name" + t.string "lowercase_name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["lowercase_name"], name: "index_tags_on_lowercase_name", unique: true + end + create_table "usda_food_weights", force: :cascade do |t| t.integer "usda_food_id", null: false t.decimal "amount", precision: 7, scale: 3 diff --git a/spec/factories/tags.rb b/spec/factories/tags.rb index c50df25..d0f0913 100644 --- a/spec/factories/tags.rb +++ b/spec/factories/tags.rb @@ -1,5 +1,5 @@ FactoryGirl.define do factory :tag do - name "MyString" + sequence(:name) { |n| "tag#{n}" } end end diff --git a/spec/models/recipe_spec.rb b/spec/models/recipe_spec.rb index d8fb105..558fc0e 100644 --- a/spec/models/recipe_spec.rb +++ b/spec/models/recipe_spec.rb @@ -28,4 +28,53 @@ RSpec.describe Recipe, type: :model do end end + + describe '#tag_names' do + + it 'should return tag names' do + t1 = create(:tag, name: 'one') + t2 = create(:tag, name: 'two') + + r = create(:recipe) + + r.tags = [t1, t2] + r.save + + expect(r.tag_names).to contain_exactly('one', 'two') + end + + it 'should set existing tags' do + t1 = create(:tag, name: 'one') + t2 = create(:tag, name: 'two') + + r = create(:recipe) + r.tag_names = ['one', 'two'] + expect(r.tags).to contain_exactly(t1, t2) + end + + it 'should set new tags' do + r = create(:recipe) + r.tag_names = ['one', 'two'] + expect(r.tags.length).to eq 2 + expect(r.tags.first.persisted?).to be_truthy + end + + it 'should set new and existing tags' do + t1 = create(:tag, name: 'one') + t2 = create(:tag, name: 'two') + + r = create(:recipe) + r.tag_names = ['One', 'TWO', 'three'] + expect(r.tags).to include(t1, t2) + expect(r.tags.length).to eq 3 + expect(r.tags.detect {|t| t.is?('THREE')}).not_to be_nil + end + + it 'ignores empty elements' do + r = create(:recipe) + r.tag_names = ['one', 'two', nil, ''] + expect(r.tags.length).to eq 2 + end + + end end diff --git a/vendor/assets/javascripts/bootstrap-tagsinput.js b/vendor/assets/javascripts/bootstrap-tagsinput.js new file mode 100644 index 0000000..823256b --- /dev/null +++ b/vendor/assets/javascripts/bootstrap-tagsinput.js @@ -0,0 +1,682 @@ +/* + * bootstrap-tagsinput v0.8.0 + * + */ + +(function ($) { + "use strict"; + + var defaultOptions = { + tagClass: function(item) { + return 'label label-info'; + }, + focusClass: 'focus', + itemValue: function(item) { + return item ? item.toString() : item; + }, + itemText: function(item) { + return this.itemValue(item); + }, + itemTitle: function(item) { + return null; + }, + freeInput: true, + addOnBlur: true, + maxTags: undefined, + maxChars: undefined, + confirmKeys: [13, 44], + delimiter: ',', + delimiterRegex: null, + cancelConfirmKeysOnEmpty: false, + onTagExists: function(item, $tag) { + $tag.hide().fadeIn(); + }, + trimValue: false, + allowDuplicates: false, + triggerChange: true + }; + + /** + * Constructor function + */ + function TagsInput(element, options) { + this.isInit = true; + this.itemsArray = []; + + this.$element = $(element); + this.$element.hide(); + + this.isSelect = (element.tagName === 'SELECT'); + this.multiple = (this.isSelect && element.hasAttribute('multiple')); + this.objectItems = options && options.itemValue; + this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : ''; + this.inputSize = Math.max(1, this.placeholderText.length); + + this.$container = $('
'); + this.$input = $('').appendTo(this.$container); + + this.$element.before(this.$container); + + this.build(options); + this.isInit = false; + } + + TagsInput.prototype = { + constructor: TagsInput, + + /** + * Adds the given item as a new tag. Pass true to dontPushVal to prevent + * updating the elements val() + */ + add: function(item, dontPushVal, options) { + var self = this; + + if (self.options.maxTags && self.itemsArray.length >= self.options.maxTags) + return; + + // Ignore falsey values, except false + if (item !== false && !item) + return; + + // Trim value + if (typeof item === "string" && self.options.trimValue) { + item = $.trim(item); + } + + // Throw an error when trying to add an object while the itemValue option was not set + if (typeof item === "object" && !self.objectItems) + throw("Can't add objects when itemValue option is not set"); + + // Ignore strings only containg whitespace + if (item.toString().match(/^\s*$/)) + return; + + // If SELECT but not multiple, remove current tag + if (self.isSelect && !self.multiple && self.itemsArray.length > 0) + self.remove(self.itemsArray[0]); + + if (typeof item === "string" && this.$element[0].tagName === 'INPUT') { + var delimiter = (self.options.delimiterRegex) ? self.options.delimiterRegex : self.options.delimiter; + var items = item.split(delimiter); + if (items.length > 1) { + for (var i = 0; i < items.length; i++) { + this.add(items[i], true); + } + + if (!dontPushVal) + self.pushVal(self.options.triggerChange); + return; + } + } + + var itemValue = self.options.itemValue(item), + itemText = self.options.itemText(item), + tagClass = self.options.tagClass(item), + itemTitle = self.options.itemTitle(item); + + // Ignore items allready added + var existing = $.grep(self.itemsArray, function(item) { return self.options.itemValue(item) === itemValue; } )[0]; + if (existing && !self.options.allowDuplicates) { + // Invoke onTagExists + if (self.options.onTagExists) { + var $existingTag = $(".tag", self.$container).filter(function() { return $(this).data("item") === existing; }); + self.options.onTagExists(item, $existingTag); + } + return; + } + + // if length greater than limit + if (self.items().toString().length + item.length + 1 > self.options.maxInputLength) + return; + + // raise beforeItemAdd arg + var beforeItemAddEvent = $.Event('beforeItemAdd', { item: item, cancel: false, options: options}); + self.$element.trigger(beforeItemAddEvent); + if (beforeItemAddEvent.cancel) + return; + + // register item in internal array and map + self.itemsArray.push(item); + + // add a tag element + + var $tag = $('' + htmlEncode(itemText) + ''); + $tag.data('item', item); + self.findInputWrapper().before($tag); + $tag.after(' '); + + // Check to see if the tag exists in its raw or uri-encoded form + var optionExists = ( + $('option[value="' + encodeURIComponent(itemValue) + '"]', self.$element).length || + $('option[value="' + htmlEncode(itemValue) + '"]', self.$element).length + ); + + // add