recipe editing

This commit is contained in:
Dan Elbert 2018-04-01 21:43:23 -05:00
parent 5579876c63
commit 1c4fa37778
30 changed files with 1409 additions and 77 deletions

View File

@ -3,6 +3,14 @@ class ApplicationController < ActionController::Base
# For APIs, you may want to use :null_session instead. # For APIs, you may want to use :null_session instead.
protect_from_forgery with: :exception protect_from_forgery with: :exception
def verified_request?
if request.content_type == "application/json"
true
else
super()
end
end
def ensure_valid_user def ensure_valid_user
unless current_user? unless current_user?
flash[:warning] = "You must login" flash[:warning] = "You must login"

View File

@ -6,7 +6,7 @@ class RecipesController < ApplicationController
# GET /recipes # GET /recipes
def index def index
@criteria = ViewModels::RecipeCriteria.new(params[:criteria]) @criteria = ViewModels::RecipeCriteria.new(criteria_params)
@criteria.page = params[:page] @criteria.page = params[:page]
@criteria.per = params[:per] @criteria.per = params[:per]
@recipes = Recipe.for_criteria(@criteria).includes(:tags) @recipes = Recipe.for_criteria(@criteria).includes(:tags)
@ -40,26 +40,6 @@ class RecipesController < ApplicationController
end end
end end
@recipe = RecipeDecorator.decorate(@recipe, view_context)
end
# GET /recipes/1
def scale
@scale = params[:factor]
@recipe.scale(@scale, true)
@recipe = RecipeDecorator.decorate(@recipe, view_context)
render :show
end
# GET /recipes/new
def new
@recipe = Recipe.new
end
# GET /recipes/1/edit
def edit
ensure_owner @recipe
end end
# POST /recipes # POST /recipes
@ -68,9 +48,9 @@ class RecipesController < ApplicationController
@recipe.user = current_user @recipe.user = current_user
if @recipe.save if @recipe.save
redirect_to @recipe, notice: 'Recipe was successfully created.' render json: { success: true }
else else
render :new render json: @recipe.errors, status: :unprocessable_entity
end end
end end
@ -78,13 +58,20 @@ class RecipesController < ApplicationController
def update def update
ensure_owner(@recipe) do ensure_owner(@recipe) do
if @recipe.update(recipe_params) if @recipe.update(recipe_params)
redirect_to @recipe, notice: 'Recipe was successfully updated.' render json: { success: true }
else else
render :edit puts '===='
puts @recipe.recipe_ingredients.map { |ri| ri.ingredient_id.class }.join("|")
render json: @recipe.errors, status: :unprocessable_entity
end end
end end
end end
# POST /recipes/preview_steps
def preview_steps
render json: { rendered_steps: MarkdownProcessor.render(params[:step_text]) }
end
# DELETE /recipes/1 # DELETE /recipes/1
def destroy def destroy
ensure_owner(@recipe) do ensure_owner(@recipe) do
@ -108,4 +95,8 @@ class RecipesController < ApplicationController
def recipe_params def recipe_params
params.require(:recipe).permit(:name, :description, :source, :yields, :total_time, :active_time, :step_text, tag_names: [], recipe_ingredients_attributes: [:name, :ingredient_id, :quantity, :units, :preparation, :sort_order, :id, :_destroy]) params.require(:recipe).permit(:name, :description, :source, :yields, :total_time, :active_time, :step_text, tag_names: [], recipe_ingredients_attributes: [:name, :ingredient_id, :quantity, :units, :preparation, :sort_order, :id, :_destroy])
end end
def criteria_params
params.require(:criteria).permit(*ViewModels::RecipeCriteria::PARAMS)
end
end end

View File

@ -1,7 +1,8 @@
<template> <template>
<div> <div>
<vue-progress-bar></vue-progress-bar>
<app-navbar></app-navbar> <app-navbar></app-navbar>
<section id="app" class="section"> <section id="app" class="">
<div class="container"> <div class="container">
<router-view v-if="!hasError"></router-view> <router-view v-if="!hasError"></router-view>
<div v-else> <div v-else>
@ -20,6 +21,11 @@
import api from "../lib/Api"; import api from "../lib/Api";
export default { export default {
data() {
return {
api: api
};
},
computed: { computed: {
...mapState({ ...mapState({
hasError: state => state.error !== null, hasError: state => state.error !== null,
@ -34,11 +40,40 @@
]) ])
}, },
mounted() { watch: {
isLoading(val) {
if (val) {
this.$Progress.start();
} else {
this.$Progress.finish();
}
}
},
created() {
if (this.user === null && this.authChecked === false) { if (this.user === null && this.authChecked === false) {
this.loadResource(api.getCurrentUser(), user => { this.loadResource(api.getCurrentUser().then(user => this.setUser(user)));
this.setUser(user); }
})
// Hard coded values taken directly from Bulma css
const mediaQueries = {
mobile: "screen and (max-width: 768px)",
tablet: "screen and (min-width: 769px)",
tabletOnly: "screen and (min-width: 769px) and (max-width: 1023px)",
touch: "screen and (max-width: 1023px)",
desktop: "screen and (min-width: 1024px)",
desktopOnly: "screen and (min-width: 1024px) and (max-width: 1215px)",
widescreen: "screen and (min-width: 1216px)",
widescreenOnly: "screen and (min-width: 1216px) and (max-width: 1407px)",
fullhd: "screen and (min-width: 1408px)"
};
for (let device in mediaQueries) {
const query = window.matchMedia(mediaQueries[device]);
query.onchange = (q) => {
this.$store.commit("setMediaQuery", {mediaName: device, value: q.matches});
};
query.onchange(query);
} }
}, },

View File

@ -0,0 +1,258 @@
<template>
<div>
<input
ref="textInput"
type="text"
autocomplete="off"
:id="id"
:name="name"
:placeholder="placeholder"
:value="rawValue"
:class="finalInputClass"
@blur="blurHandler"
@input="inputHandler"
@keydown="keydownHandler"
/>
<div v-show="isListOpen" class="list">
<ul>
<li v-for="(opt, idx) in options" :key="optionKey(opt)" :class="optionClass(idx)" @mousemove="optionMousemove(idx)" @click="optionClick(opt)">
<b class="opt_value">{{ optionValue(opt) }}</b>
<span v-if="optionLabel(opt) !== null" class="opt_label" v-html="optionLabel(opt)"></span>
</li>
</ul>
</div>
</div>
</template>
<script>
import debounce from 'lodash/debounce';
export default {
props: {
value: String,
id: String,
placeholder: String,
name: String,
inputClass: {
type: [String, Object, Array],
required: false,
default: null
},
minLength: {
type: Number,
default: 0
},
debounce: {
type: Number,
required: false,
default: 250
},
valueAttribute: String,
labelAttribute: String,
onGetOptions: Function,
searchOptions: Array
},
data() {
return {
options: [],
rawValue: "",
isListOpen: false,
activeListIndex: 0
}
},
created() {
this.rawValue = this.value;
},
watch: {
value(newValue) {
this.rawValue = newValue;
}
},
computed: {
finalInputClass() {
let cls = ['input'];
if (this.inputClass === null) {
return cls;
} else if (Array.isArray(this.inputClass)) {
return cls.concat(this.inputClass);
} else {
cls.push(this.inputClass);
return cls;
}
},
debouncedUpdateOptions() {
return debounce(this.updateOptions, this.debounce);
}
},
methods: {
optionClass(idx) {
return this.activeListIndex === idx ? 'option active' : 'option';
},
optionClick(opt) {
this.selectOption(opt);
},
optionKey(opt) {
if (this.valueAttribute) {
return opt[this.valueAttribute];
} else {
return opt.toString();
}
},
optionValue(opt) {
return this.optionKey(opt);
},
optionLabel(opt) {
if (this.labelAttribute) {
return opt[this.labelAttribute];
} else {
return null;
}
},
optionMousemove(idx) {
this.activeListIndex = idx;
},
blurHandler(evt) {
// blur fires before click. If the blur was fired because the user clicked a list item, immediately hiding the list here
// would prevent the click event from firing
setTimeout(() => {
this.isListOpen = false;
},250);
},
inputHandler(evt) {
const newValue = evt.target.value;
if (this.rawValue !== newValue) {
this.rawValue = newValue;
this.$emit("input", newValue);
if (newValue.length >= Math.max(1, this.minLength)) {
this.debouncedUpdateOptions(newValue);
} else {
this.isListOpen = false;
}
}
},
keydownHandler(evt) {
if (this.isListOpen === false)
return;
switch (evt.key) {
case "ArrowUp":
evt.preventDefault();
this.activeListIndex = Math.max(0, this.activeListIndex - 1);
break;
case "ArrowDown":
evt.preventDefault();
this.activeListIndex = Math.min(this.options.length - 1, this.activeListIndex + 1);
break;
case "Enter":
evt.preventDefault();
this.selectOption(this.options[this.activeListIndex]);
break;
case "Escape":
evt.preventDefault();
this.isListOpen = false;
break;
}
},
selectOption(opt) {
this.rawValue = this.optionValue(opt);
this.$emit("input", this.rawValue);
this.$emit("optionSelected", opt);
this.isListOpen = false;
},
updateOptions(value) {
let p = null;
if (this.searchOptions) {
const reg = new RegExp("^" + value, "i");
const matcher = o => reg.test(this.optionValue(o));
p = Promise.resolve(this.searchOptions.filter(matcher));
} else {
p = this.onGetOptions(value)
}
p.then(opts => {
this.options = opts;
this.isListOpen = opts.length > 0;
this.activeListIndex = 0;
})
}
}
}
</script>
<style lang="scss" scoped>
@import "../styles/variables";
$labelLineHeight: 0.8rem;
input.input {
&::placeholder {
color: $grey-darker;
}
}
.list {
position: relative;
z-index: 150;
ul {
background-color: white;
position: absolute;
width: 100%;
border: 1px solid black;
}
}
li.option {
padding: 4px;
margin-bottom: 2px;
//transition: background-color 0.25s;
&.active {
color: white;
background-color: $turquoise;
}
.opt_value {
}
.opt_label {
display: block;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.8rem;
line-height: $labelLineHeight;
max-height: $labelLineHeight * 2;
}
}
</style>

View File

@ -6,16 +6,20 @@
<script> <script>
import X from "open-iconic/svg/x"; import CaretBottom from "open-iconic/svg/caret-bottom";
import Person from "open-iconic/svg/person"; import CaretTop from "open-iconic/svg/caret-top";
import LockLocked from "open-iconic/svg/lock-locked"; import LockLocked from "open-iconic/svg/lock-locked";
import LockUnlocked from "open-iconic/svg/lock-unlocked"; import LockUnlocked from "open-iconic/svg/lock-unlocked";
import Person from "open-iconic/svg/person";
import X from "open-iconic/svg/x";
const iconMap = { const iconMap = {
x: X, 'caret-bottom': CaretBottom,
person: Person, 'caret-top': CaretTop,
'lock-locked': LockLocked, 'lock-locked': LockLocked,
'lock-unlocked': LockUnlocked 'lock-unlocked': LockUnlocked,
person: Person,
x: X
}; };
const sizeMap = { const sizeMap = {

View File

@ -1,14 +1,156 @@
<template> <template>
<div>
<h1 class="title">{{ action }} {{ recipe.name || "[Unamed Recipe]" }}</h1>
<div class="columns">
<div class="column">
<div class="field">
<label class="label is-small-mobile">Name</label>
<div class="control">
<input class="input is-small-mobile" type="text" placeholder="name" v-model="recipe.name">
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label is-small-mobile">Source</label>
<div class="control">
<input class="input is-small-mobile" type="text" placeholder="source" v-model="recipe.source">
</div>
</div>
</div>
</div>
<div class="field">
<label class="label is-small-mobile">Description</label>
<div class="control">
<textarea class="textarea is-small-mobile" placeholder="description" v-model="recipe.description"></textarea>
</div>
</div>
<div class="field">
<label class="label is-small-mobile">Tags</label>
<div class="control">
<input class="input is-small-mobile" type="text" placeholder="tags">
</div>
</div>
<div class="columns">
<div class="column">
<div class="field">
<label class="label is-small-mobile">Yields</label>
<div class="control">
<input class="input is-small-mobile" type="text" placeholder="servings" v-model="recipe.yields">
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label is-small-mobile">Total Time</label>
<div class="control">
<input class="input is-small-mobile" type="number" placeholder="minutes" v-model="recipe.total_time">
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label is-small-mobile">Active Time</label>
<div class="control">
<input class="input is-small-mobile" type="number" placeholder="minutes" v-model="recipe.active_time">
</div>
</div>
</div>
</div>
<h3 class="title is-4">Ingredients</h3>
<recipe-edit-ingredient-editor :ingredients="recipe.ingredients"></recipe-edit-ingredient-editor>
<div class="field">
<label class="label title is-4">Directions</label>
<div class="control columns">
<div class="column">
<textarea ref="step_text_area" class="textarea" v-model="recipe.step_text"></textarea>
</div>
<div class="column">
<div class="box content" v-html="stepPreview">
</div>
</div>
</div>
</div>
</div>
</template> </template>
<script> <script>
export default { import autosize from "autosize";
import debounce from "lodash/debounce";
import api from "../lib/Api";
import RecipeEditIngredientEditor from "./RecipeEditIngredientEditor";
export default {
props: {
recipe: {
required: true,
type: Object
},
action: {
required: false,
type: String,
default: "Editing"
}
},
data() {
return {
stepPreviewCache: null
};
},
computed: {
stepPreview() {
if (this.stepPreviewCache === null) {
return this.recipe.rendered_steps;
} else {
return this.stepPreviewCache;
}
}
},
methods: {
updatePreview: debounce(function() {
api.postPreviewSteps(this.recipe.step_text)
.then(data => this.stepPreviewCache = data.rendered_steps)
.catch(err => this.stepPreviewCache = "?? Error ??");
}, 750)
},
watch: {
'recipe.step_text': function() {
this.updatePreview();
}
},
mounted() {
autosize(this.$refs.step_text_area);
},
components: {
RecipeEditIngredientEditor
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.bulk-input {
height: 100%;
}
</style> </style>

View File

@ -0,0 +1,169 @@
<template>
<div>
<button type="button" class="button is-primary" @click="bulkEditIngredients">Bulk Edit</button>
<app-modal :open="isBulkEditing" title="Edit Ingredients" @dismiss="cancelBulkEditing">
<div class="columns is-mobile">
<div class="column is-half">
<textarea ref="bulkEditTextarea" class="textarea is-size-7 bulk-input" v-model="bulkEditText"></textarea>
</div>
<div class="column is-half">
<table class="table is-bordered is-narrow is-size-7">
<tr>
<th>#</th>
<th>Unit</th>
<th>Name</th>
<th>Prep</th>
</tr>
<tr v-for="i in bulkIngredientPreview">
<td>{{i.quantity}}</td>
<td>{{i.units}}</td>
<td>{{i.name}}</td>
<td>{{i.preparation}}</td>
</tr>
</table>
</div>
</div>
<button class="button is-primary" type="button" @click="saveBulkEditing">Save</button>
<button class="button is-secondary" type="button" @click="cancelBulkEditing">Cancel</button>
</app-modal>
<recipe-edit-ingredient-item v-for="(i, idx) in visibleIngredients" :key="i.id" :ingredient="i" :show-labels="idx === 0 || isMobile" @deleteIngredient="deleteIngredient"></recipe-edit-ingredient-item>
<button type="button" class="button is-primary" @click="addIngredient">Add Ingredient</button>
</div>
</template>
<script>
import AppModal from "./AppModal";
import RecipeEditIngredientItem from "./RecipeEditIngredientItem";
import { mapState } from "vuex";
export default {
props: {
ingredients: {
required: true,
type: Array
}
},
data() {
return {
isBulkEditing: false,
bulkEditText: null
};
},
computed: {
...mapState({
isMobile: state => state.mediaQueries.mobile
}),
bulkIngredientPreview() {
if (this.bulkEditText === null) {
return [];
}
const regex = /^(?:([\d\/.]+(?:\s+[\d\/]+)?)\s+)?(?:([\w-]+)(?:\s+of)?\s+)?([^,]*)(?:,\s*(.*))?$/i;
const magicFunc = function(str) {
if (str === "-") {
return "";
} else {
return str;
}
};
const parsed = [];
const lines = this.bulkEditText.replace("\r", "").split("\n");
for (let line of lines) {
if (line.length === 0) { continue; }
const barIndex = line.lastIndexOf("|");
let afterBar = null;
if (barIndex >= 0) {
afterBar = line.slice(barIndex + 1);
line = line.slice(0, barIndex);
}
const match = line.match(regex);
if (match) {
let 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);
}
}
return parsed;
},
visibleIngredients() {
return this.ingredients.filter(i => i._destroy !== true);
}
},
methods: {
addIngredient() {
this.ingredients.push({
quantity: null,
units: null,
name: null,
preparation: null,
ingredient_id: null,
sort_order: Math.max([0].concat(this.ingredients.map(i => i.sort_order))) + 5
});
},
deleteIngredient(ingredient) {
if (ingredient.id) {
ingredient._destroy = true;
} else {
const idx = this.ingredients.findIndex(i => i === ingredient);
this.ingredients.splice(idx, 1);
}
},
bulkEditIngredients() {
this.isBulkEditing = true;
let text = [];
for (let item of this.visibleIngredients) {
text.push(
item.quantity + " " +
(item.units || "-") + " " +
item.name +
(item.preparation ? (", " + item.preparation) : "")
);
}
this.bulkEditText = text.join("\n");
},
cancelBulkEditing() {
this.isBulkEditing = false;
},
saveBulkEditing() {
this.isBulkEditing = false;
}
},
components: {
AppModal,
RecipeEditIngredientItem
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,95 @@
<template>
<div class="columns is-mobile">
<div class="column">
<div class="columns is-multiline is-mobile">
<div class="column is-half-mobile is-2-tablet">
<span class="label is-small-mobile" v-if="showLabels">Quantity</span>
<input class="input is-small-mobile" type="text" v-model="ingredient.quantity">
</div>
<div class="column is-half-mobile is-3-tablet">
<span class="label is-small-mobile" v-if="showLabels">Units</span>
<input class="input is-small-mobile" type="text" v-model="ingredient.units">
</div>
<div class="column is-half-mobile is-3-tablet">
<span class="label is-small-mobile" v-if="showLabels">Name</span>
<app-autocomplete
:inputClass="{'is-small-mobile': true, 'is-success': ingredient.ingredient_id !== null}"
ref="autocomplete"
v-model="ingredient.name"
:minLength="2"
valueAttribute="name"
labelAttribute="name"
placeholder=""
@optionSelected="searchItemSelected"
:onGetOptions="updateSearchItems"
>
</app-autocomplete>
</div>
<div class="column is-half-mobile is-4-tablet">
<span class="label is-small-mobile" v-if="showLabels">Preparation</span>
<input class="input is-small-mobile" type="text" v-model="ingredient.preparation">
</div>
</div>
</div>
<div class="column is-narrow">
<span class="label is-small-mobile" v-if="showLabels">&nbsp;</span>
<button type="button" class="button is-danger is-small-mobile" @click="deleteIngredient(ingredient)">X</button>
</div>
</div>
</template>
<script>
import AppAutocomplete from "./AppAutocomplete";
import api from "../lib/Api";
export default {
props: {
ingredient: {
required: true,
type: Object
},
showLabels: {
required: false,
type: Boolean,
default: false
}
},
methods: {
deleteIngredient(ingredient) {
this.$emit("deleteIngredient", ingredient);
},
updateSearchItems(text) {
return api.getSearchIngredients(text);
},
searchItemSelected(ingredient) {
this.ingredient.ingredient_id = ingredient.id;
this.ingredient.ingredient = ingredient;
this.ingredient.name = ingredient.name;
}
},
watch: {
'ingredient.name': function(val) {
if (this.ingredient.ingredient && this.ingredient.ingredient.name !== val) {
this.ingredient.ingredient_id = null;
this.ingredient.ingredient = null;
}
}
},
components: {
AppAutocomplete
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -1,11 +1,94 @@
<template> <template>
<div>
<div v-if="recipe === null">
Loading...
</div>
<div v-else>
<h1 class="title">{{ recipe.name }}</h1>
<div class="subtitle tags">
<span v-for="tag in recipe.tags" :key="tag" class="tag is-medium">{{tag}}</span>
</div>
<hr>
<div class="level is-mobile">
<div class="level-item">
{{ recipe.total_time}} ({{recipe.active_time}})
</div>
<div class="level-item">
<p>Yields</p>
<p>{{recipe.yields}}</p>
</div>
<div class="level-item">
<p>Source</p>
<p>{{recipe.source}}</p>
</div>
</div>
<div class="columns">
<div class="column">
<div class="message">
<div class="message-header">Ingredients</div>
<div class="message-body content">
<ul v-if="recipe.ingredients.length > 0">
<li v-for="i in recipe.ingredients">
{{i.display_name}}
</li>
</ul>
</div>
</div>
</div>
<div class="column">
<div class="message">
<div class="message-header">Directions</div>
<div class="message-body content" v-html="recipe.rendered_steps">
</div>
</div>
</div>
</div>
<div class="message">
<div class="message-header" @click="showNutrition = !showNutrition">Nutrition Data</div>
<div class="message-body" v-show="showNutrition">
<table class="table">
<tr>
<th>Item</th>
<th>Value</th>
</tr>
<tr v-for="nutrient in recipe.nutrition_data.nutrients" :key="nutrient.name">
<td>{{nutrient.label}}</td>
<td>{{nutrient.value}}</td>
</tr>
</table>
<h3 class="title is-5">Nutrition Calculation Warnings</h3>
<ul>
<li v-for="warn in recipe.nutrition_data.errors" :key="warn">
{{warn}}
</li>
</ul>
</div>
</div>
</div>
</div>
</template> </template>
<script> <script>
export default { export default {
props: {
recipe: {
required: true,
type: Object
}
},
data() {
return {
showNutrition: false
};
}
} }
</script> </script>

View File

@ -0,0 +1,50 @@
<template>
<div>
<div v-if="recipe === null">
Loading...
</div>
<div v-else>
<recipe-show :recipe="recipe"></recipe-show>
</div>
<router-link :to="{name: 'edit_recipe', params: { id: recipeId }}">Edit</router-link>
<router-link to="/">Back</router-link>
</div>
</template>
<script>
import RecipeShow from "./RecipeShow";
import { mapState } from "vuex";
import api from "../lib/Api";
export default {
data: function () {
return {
recipe: null,
showNutrition: false
}
},
computed: {
...mapState({
recipeId: state => state.route.params.id,
})
},
created() {
this.loadResource(
api.getRecipe(this.recipeId)
.then(data => { this.recipe = data; return data; })
);
},
components: {
RecipeShow
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,46 @@
<template>
<div>
<recipe-edit :recipe="recipe" action="Creating"></recipe-edit>
</div>
</template>
<script>
import RecipeEdit from "./RecipeEdit";
import { mapState } from "vuex";
import api from "../lib/Api";
export default {
data() {
return {
recipe: {
name: null,
source: null,
description: null,
yields: null,
total_time: null,
active_time: null,
step_text: null,
tags: [],
ingredients: []
}
}
},
computed: {
...mapState({
recipeId: state => state.route.params.id,
})
},
components: {
RecipeEdit
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,63 @@
<template>
<div>
<div v-if="recipe === null">
Loading...
</div>
<div v-else>
<recipe-edit :recipe="recipe"></recipe-edit>
</div>
<button type="button" class="button is-primary" @click="save">Save</button>
<router-link class="button is-secondary" to="/">Cancel</router-link>
</div>
</template>
<script>
import RecipeEdit from "./RecipeEdit";
import { mapState } from "vuex";
import api from "../lib/Api";
import * as Errors from '../lib/Errors';
export default {
data: function () {
return {
recipe: null,
validationErrors: []
}
},
computed: {
...mapState({
recipeId: state => state.route.params.id,
})
},
methods: {
save() {
this.loadResource(
api.patchRecipe(this.recipe)
.then(() => this.$router.push({name: 'recipe', params: {id: this.recipeId }}))
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.validationErrors = err.validationErrors()))
);
}
},
created() {
this.loadResource(
api.getRecipe(this.recipeId)
.then(data => { this.recipe = data; return data; })
);
},
components: {
RecipeEdit
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -2,17 +2,54 @@
<div> <div>
<h1 class="title">Recipes</h1> <h1 class="title">Recipes</h1>
<button type="button" class="button">Create Recipe</button> <router-link :to="{name: 'new_recipe'}" class="button is-primary">Create Recipe</router-link>
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th v-for="h in tableHeader" :key="h.name">
<a v-if="h.sort" href="#" @click.prevent="setSort(h.name)">
{{h.label}}
<app-icon v-if="search.sortColumn === h.name" size="sm" :icon="search.sortDirection === 'desc' ? 'caret-bottom' : 'caret-top'"></app-icon>
</a>
<span v-else>{{h.label}}</span>
</th>
<th></th>
</tr>
<tr>
<td>
<div class="field">
<div class="control">
<input type="text" class="input" placeholder="search names" v-model="search.name">
</div>
</div>
</td>
<td>
<div class="field">
<div class="control">
<input type="text" class="input" placeholder="search tags" v-model="search.tags">
</div>
</div>
</td>
<td colspan="5"></td>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="r in recipes" :key="r.id"> <tr v-for="r in recipes" :key="r.id">
<td>{{r.name}}</td> <td><router-link :to="{name: 'recipe', params: { id: r.id } }">{{r.name}}</router-link></td>
<td>
<div class="tags">
<span class="tag" v-for="tag in r.tags" :key="tag">{{tag}}</span>
</div>
</td>
<td>{{r.rating}}</td>
<td>{{r.yields}}</td>
<td>{{r.total_time}} ({{r.active_time}})</td>
<td>{{r.created_at}}</td>
<td>
<router-link :to="{name: 'edit_recipe', params: { id: r.id } }" class="button">Edit</router-link>
<button type="button" class="button">Delete</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -23,20 +60,24 @@
<script> <script>
import api from "../lib/Api"; import api from "../lib/Api";
import debounce from "lodash/debounce";
import AppIcon from "./AppIcon";
export default { export default {
data() { data() {
return { return {
recipeData: null recipeData: null,
search: {
sortColumn: 'name',
sortDirection: 'desc',
page: 1,
per: 25,
name: null,
tags: null
}
}; };
}, },
created() {
this.loadResource(api.getRecipeList(), data => this.recipeData = data);
},
computed: { computed: {
recipes() { recipes() {
if (this.recipeData) { if (this.recipeData) {
@ -44,7 +85,50 @@
} else { } else {
return []; return [];
} }
},
tableHeader() {
return [
{name: 'name', label: 'Name', sort: true},
{name: 'tags', label: 'Tags', sort: false},
{name: 'rating', label: 'Rating', sort: true},
{name: 'yields', label: 'Yields', sort: false},
{name: 'total_time', label: 'Time', sort: true},
{name: 'created_at', label: 'Created', sort: true}
]
} }
},
methods: {
setSort(col) {
if (this.search.sortColumn === col) {
this.search.sortDirection = this.search.sortDirection === "desc" ? "asc" : "desc";
} else {
this.search.sortColumn = col;
this.search.sortDirection = "desc";
}
},
getList: debounce(function() {
this.loadResource(
api.getRecipeList(this.search.page, this.search.per, this.search.sortColumn, this.search.sortDirection, this.search.name, this.search.tags)
.then(data => this.recipeData = data)
);
}, 500, {leading: true, trailing: true})
},
created() {
this.$watch("search",
() => this.getList(),
{
deep: true,
immediate: true
}
);
},
components: {
AppIcon
} }
} }

View File

@ -75,14 +75,14 @@
login() { login() {
if (this.username !== '' && this.password != '') { if (this.username !== '' && this.password != '') {
this.loadResource(api.postLogin(this.username, this.password), data => { this.loadResource(api.postLogin(this.username, this.password).then(data => {
if (data.success) { if (data.success) {
this.setUser(data.user); this.setUser(data.user);
this.showLogin = false; this.showLogin = false;
} else { } else {
this.error = data.message; this.error = data.message;
} }
}); }));
} }
} }
}, },

View File

@ -16,6 +16,8 @@ class Api {
return response; return response;
} else if (response.status === 404) { } else if (response.status === 404) {
throw new Errors.ApiNotFoundError(response.statusText, response); throw new Errors.ApiNotFoundError(response.statusText, response);
} else if (response.status === 422) {
return response.json().then(json => { throw new Errors.ApiValidationError(null, response, json) }, jsonErr => { throw new Errors.ApiValidationError(null, response, null) });
} else { } else {
throw new Errors.ApiServerError(response.statusText || "Unknown Server Error", response); throw new Errors.ApiServerError(response.statusText || "Unknown Server Error", response);
} }
@ -45,21 +47,28 @@ class Api {
return fetch(url, opts).then(this.checkStatus).then(this.parseJSON); return fetch(url, opts).then(this.checkStatus).then(this.parseJSON);
} }
get(url, params = {}) { objectToUrlParams(obj, queryParams = [], prefixes = []) {
for (let key in obj) {
const queryParams = []; const val = obj[key];
const paramName = prefixes.join("[") + "[".repeat(Math.min(prefixes.length, 1)) + encodeURIComponent(key) + "]".repeat(prefixes.length);
for (let key in params) {
const val = params[key];
if (Array.isArray(val)) { if (Array.isArray(val)) {
for (let x of val) { for (let x of val) {
queryParams.push(encodeURIComponent(key) + "[]=" + (x === null ? '' : encodeURIComponent(x))); queryParams.push(paramName + "[]=" + (x === null ? '' : encodeURIComponent(x)));
} }
} else if (typeof(val) === "object") {
this.objectToUrlParams(val, queryParams, prefixes.concat([key]));
} else { } else {
queryParams.push(encodeURIComponent(key) + "=" + (val === null ? '' : encodeURIComponent(val))); queryParams.push(paramName + "=" + (val === null ? '' : encodeURIComponent(val)));
} }
} }
return queryParams;
}
get(url, params = {}) {
const queryParams = this.objectToUrlParams(params);
if (queryParams.length) { if (queryParams.length) {
url = url + "?" + queryParams.join("&"); url = url + "?" + queryParams.join("&");
} }
@ -71,19 +80,77 @@ class Api {
return this.performRequest(url, "POST", params); return this.performRequest(url, "POST", params);
} }
patch(url, params = {}) {
return this.performRequest(url, "PATCH", params);
}
getRecipeList(page, per, sortColumn, sortDirection, name, tags) { getRecipeList(page, per, sortColumn, sortDirection, name, tags) {
const params = { const params = {
page: page || null, criteria: {
per: per || null, page: page || null,
sort_column: sortColumn || null, per: per || null,
sort_direction: sortDirection || null, sort_column: sortColumn || null,
name: name || null, sort_direction: sortDirection || null,
tags: tags || null name: name || null,
tags: tags || null
}
}; };
return this.get("/recipes", params); return this.get("/recipes", params);
} }
getRecipe(id) {
return this.get("/recipes/" + id);
}
patchRecipe(recipe) {
const params = {
recipe: {
name: recipe.name,
description: recipe.description,
source: recipe.source,
yields: recipe.yields,
total_time: recipe.total_time,
active_time: recipe.active_time,
step_text: recipe.step_text,
tag_names: recipe.tag_names,
recipe_ingredients_attributes: recipe.ingredients.map(i => {
if (i._destroy) {
return {
id: i.id,
_destroy: true
};
} else {
return {
id: i.id,
name: i.name,
ingredient_id: null,
quantity: i.quantity,
units: i.units,
preparation: i.preparation,
sort_order: i.sort_order
};
}
})
}
};
return this.patch("/recipes/" + recipe.id, params);
}
postPreviewSteps(step_text) {
const params = {
step_text: step_text
};
return this.post("/recipes/preview_steps", params);
}
getSearchIngredients(query) {
const params = { query: query };
return this.get("/ingredients/search", params);
}
postLogin(username, password) { postLogin(username, password) {
const params = { const params = {
username: username, username: username,

View File

@ -23,6 +23,7 @@ ApiServerError.prototype = Object.assign(new ApiError(), {
name: "ApiServerError" name: "ApiServerError"
}); });
export function ApiNotFoundError(message, response) { export function ApiNotFoundError(message, response) {
this.message = (message || "Unknown API Server Error Occurred"); this.message = (message || "Unknown API Server Error Occurred");
this.response = response; this.response = response;
@ -32,6 +33,40 @@ ApiNotFoundError.prototype = Object.assign(new ApiError(), {
}); });
export function ApiValidationError(message, response, json) {
this.message = (message || "Server returned a validation error");
this.response = response;
this.json = json;
}
ApiValidationError.prototype = Object.assign(new ApiError(), {
name: "ApiValidationError",
validationErrors: function() {
const errors = new Map();
if (this.json) {
for (let key in this.json) {
errors.set(key, this.json[key]);
}
} else {
errors.set("base", ["unknown error"]);
}
return errors;
},
formattedValidationErrors: function() {
const errors = [];
if (this.json) {
for (let key in this.json) {
errors.push(key + ": " + this.json[key].join(", "));
}
return errors;
} else {
return ["unable to determine validation errors"];
}
}
});
export function onlyFor(errorType, handler) { export function onlyFor(errorType, handler) {
return (err) => { return (err) => {
if (err instanceof errorType) { if (err instanceof errorType) {

View File

@ -19,11 +19,10 @@ Vue.mixin({
'setLoading' 'setLoading'
]), ]),
loadResource(promise, successFunc) { loadResource(promise) {
this.setLoading(true); this.setLoading(true);
return promise return promise
.then(successFunc)
.catch(err => this.setError(err)) .catch(err => this.setError(err))
.then(() => this.setLoading(false)); .then(() => this.setLoading(false));
} }

View File

@ -2,13 +2,27 @@ import '../styles';
import Vue from 'vue' import Vue from 'vue'
import { sync } from 'vuex-router-sync'; import { sync } from 'vuex-router-sync';
import VueProgressBar from "vue-progressbar";
import config from '../config'; import config from '../config';
import store from '../store'; import store from '../store';
import router from '../router'; import router from '../router';
import '../lib/GlobalMixins'; import '../lib/GlobalMixins';
import App from '../components/App'; import App from '../components/App';
//Vue.use(VueClipboard); Vue.use(VueProgressBar, {
// color: '#bffaf3',
// failedColor: '#874b4b',
// thickness: '5px',
// transition: {
// speed: '0.2s',
// opacity: '0.6s',
// termination: 300
// },
// autoRevert: true,
// location: 'left',
// inverse: false
});
sync(store, router); sync(store, router);
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@ -1,13 +1,14 @@
import Vue from 'vue'; import Vue from 'vue';
import Router from 'vue-router'; import Router from 'vue-router';
import RecipeEdit from './components/RecipeEdit';
import RecipeShow from './components/RecipeShow';
import The404Page from './components/The404Page'; import The404Page from './components/The404Page';
import TheAboutPage from './components/TheAboutPage'; import TheAboutPage from './components/TheAboutPage';
import TheCalculator from './components/TheCalculator'; import TheCalculator from './components/TheCalculator';
import TheIngredientList from './components/TheIngredientList'; import TheIngredientList from './components/TheIngredientList';
import TheNotesList from './components/TheNotesList'; import TheNotesList from './components/TheNotesList';
import TheRecipe from './components/TheRecipe';
import TheRecipeEditor from './components/TheRecipeEditor';
import TheRecipeCreator from './components/TheRecipeCreator';
import TheRecipeList from './components/TheRecipeList'; import TheRecipeList from './components/TheRecipeList';
Vue.use(Router); Vue.use(Router);
@ -20,18 +21,27 @@ router.addRoutes(
[ [
{ {
path: '/', path: '/',
redirect: '/recipes'
},
{
path: '/recipes',
name: 'recipeList', name: 'recipeList',
component: TheRecipeList component: TheRecipeList
}, },
{
path: '/recipes/new',
name: 'new_recipe',
component: TheRecipeCreator
},
{ {
path: '/recipes/:id/edit', path: '/recipes/:id/edit',
name: 'edit_recipe', name: 'edit_recipe',
component: RecipeEdit component: TheRecipeEditor
}, },
{ {
path: '/recipe/:id', path: '/recipe/:id',
name: 'recipe', name: 'recipe',
component: RecipeShow component: TheRecipe
}, },
{ {
path: "/about", path: "/about",

View File

@ -11,7 +11,20 @@ export default new Vuex.Store({
loading: false, loading: false,
error: null, error: null,
authChecked: false, authChecked: false,
user: null user: null,
// MediaQueryList objects in the root App component maintain this state.
mediaQueries: {
mobile: false,
tablet: false,
tabletOnly: false,
touch: false,
desktop: false,
desktopOnly: false,
widescreen: false,
widescreenOnly: false,
fullhd: false
}
}, },
getters: { getters: {
isLoading(state) { isLoading(state) {
@ -36,6 +49,10 @@ export default new Vuex.Store({
setUser(state, user) { setUser(state, user) {
state.authChecked = true; state.authChecked = true;
state.user = user; state.user = user;
},
setMediaQuery(state, data) {
state.mediaQueries[data.mediaName] = data.value;
} }
}, },
actions: { actions: {

View File

@ -0,0 +1,107 @@
@mixin responsive-button-size($size) {
&.is-small-#{$size} {
@include button-small;
}
&.is-medium-#{$size} {
@include button-medium;
}
&.is-large-#{$size} {
@include button-large;
}
}
@mixin responsive-label-size($size) {
&.is-small-#{$size} {
font-size: $size-small;
}
&.is-medium-#{$size} {
font-size: $size-medium;
}
&.is-large-#{$size} {
font-size: $size-large;
}
}
@mixin responsive-control-size($size) {
&.is-small-#{$size} {
@include control-small;
}
&.is-medium-#{$size} {
@include control-medium;
}
&.is-large-#{$size} {
@include control-large;
}
}
.button {
@include mobile {
@include responsive-button-size("mobile");
}
@include tablet {
@include responsive-button-size("tablet");
}
@include desktop {
@include responsive-button-size("desktop");
}
@include widescreen {
@include responsive-button-size("widescreen");
}
@include fullhd {
@include responsive-button-size("fullhd");
}
}
.label {
@include mobile {
@include responsive-label-size("mobile");
}
@include tablet {
@include responsive-label-size("tablet");
}
@include desktop {
@include responsive-label-size("desktop");
}
@include widescreen {
@include responsive-label-size("widescreen");
}
@include fullhd {
@include responsive-label-size("fullhd");
}
}
.input, .textarea {
@include mobile {
@include responsive-control-size("mobile");
}
@include tablet {
@include responsive-control-size("tablet");
}
@include desktop {
@include responsive-control-size("desktop");
}
@include widescreen {
@include responsive-control-size("widescreen");
}
@include fullhd {
@include responsive-control-size("fullhd");
}
}

View File

@ -3,6 +3,8 @@
$green: #79A736; $green: #79A736;
$primary: $green; $primary: $green;
$modal-content-width: 750px;
// Make all Bulma variables and functions available // Make all Bulma variables and functions available
@import "~bulma/sass/utilities/initial-variables"; @import "~bulma/sass/utilities/initial-variables";

View File

@ -3,16 +3,24 @@
@import "~bulma/sass/utilities/_all"; @import "~bulma/sass/utilities/_all";
@import "~bulma/sass/base/_all"; @import "~bulma/sass/base/_all";
@import "~bulma/sass/components/navbar"; @import "~bulma/sass/components/navbar";
@import "~bulma/sass/components/level";
@import "~bulma/sass/components/message";
@import "~bulma/sass/components/modal"; @import "~bulma/sass/components/modal";
@import "~bulma/sass/elements/_all"; @import "~bulma/sass/elements/_all";
@import "~bulma/sass/grid/columns"; @import "~bulma/sass/grid/columns";
@import "~bulma/sass/layout/section"; @import "~bulma/sass/layout/section";
@import "./responsive_controls";
body { body {
background-color: $grey-dark; background-color: $grey-dark;
} }
#app .container { #app {
padding: 1rem; padding-top: 1rem;
background-color: $background;
.container {
padding: 1rem;
background-color: $white;
}
} }

View File

@ -1,6 +1,6 @@
class RecipeIngredient < ApplicationRecord class RecipeIngredient < ApplicationRecord
belongs_to :ingredient belongs_to :ingredient, optional: true
belongs_to :recipe, inverse_of: :recipe_ingredients belongs_to :recipe, inverse_of: :recipe_ingredients
validates :sort_order, presence: true validates :sort_order, presence: true

View File

@ -10,7 +10,7 @@ module ViewModels
params ||= {} params ||= {}
PARAMS.each do |attr| PARAMS.each do |attr|
setter = "#{attr}=" setter = "#{attr}="
if params[attr] if params[attr].present?
self.send(setter, params[attr]) self.send(setter, params[attr])
end end
end end

View File

@ -0,0 +1,31 @@
json.extract! @recipe, :id, :name, :rating, :yields, :total_time, :active_time, :created_at, :updated_at, :step_text
json.rendered_steps MarkdownProcessor.render(@recipe.step_text)
json.tags @recipe.tag_names
json.ingredients @recipe.recipe_ingredients do |ri|
json.extract! ri, :id, :ingredient_id, :display_name, :name, :quantity, :units, :preparation, :sort_order
json.ingredient do
if ri.ingredient.nil?
json.null!
else
json.extract! ri.ingredient, :id, :name, :density, :notes
end
end
json._destroy false
end
json.nutrition_data do
json.errors @recipe.nutrition_data.errors
json.nutrients NutritionData::NUTRIENTS.select { |_, v| v.present? } do |name, label|
json.name name
json.label label
json.value @recipe.nutrition_data.send(name)
end
end

View File

@ -2,6 +2,9 @@ Rails.application.routes.draw do
resources :notes resources :notes
resources :recipes do resources :recipes do
collection do
post :preview_steps
end
resources :logs, only: [:new, :create] resources :logs, only: [:new, :create]
end end

View File

@ -38,7 +38,7 @@ development:
host: localhost host: localhost
port: 3035 port: 3035
public: localhost:3035 public: localhost:3035
hmr: false hmr: true
# Inline should be set to true if using HMR # Inline should be set to true if using HMR
inline: true inline: true
overlay: true overlay: true

View File

@ -1,13 +1,16 @@
{ {
"dependencies": { "dependencies": {
"@rails/webpacker": "^3.4.1", "@rails/webpacker": "^3.4.1",
"autosize": "^4.0.1",
"bulma": "^0.6.2", "bulma": "^0.6.2",
"caniuse-lite": "^1.0.30000815", "caniuse-lite": "^1.0.30000815",
"css-loader": "^0.28.11", "css-loader": "^0.28.11",
"lodash": "^4.17.5",
"open-iconic": "^1.1.1", "open-iconic": "^1.1.1",
"svg-loader": "^0.0.2", "svg-loader": "^0.0.2",
"vue": "^2.5.16", "vue": "^2.5.16",
"vue-loader": "^14.2.2", "vue-loader": "^14.2.2",
"vue-progressbar": "^0.7.4",
"vue-router": "^3.0.1", "vue-router": "^3.0.1",
"vue-template-compiler": "^2.5.16", "vue-template-compiler": "^2.5.16",
"vuex": "^3.0.1", "vuex": "^3.0.1",

View File

@ -280,6 +280,10 @@ autoprefixer@^7.1.1:
postcss "^6.0.17" postcss "^6.0.17"
postcss-value-parser "^3.2.3" postcss-value-parser "^3.2.3"
autosize@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/autosize/-/autosize-4.0.1.tgz#4e2f89d00e290dd98e1f95555a8ccb91e9c7a41a"
aws-sign2@~0.6.0: aws-sign2@~0.6.0:
version "0.6.0" version "0.6.0"
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f"
@ -3274,7 +3278,7 @@ lodash.uniq@^4.5.0:
version "4.5.0" version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.14.0, lodash@^4.17.2, lodash@^4.17.4, lodash@~4.17.4: "lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.14.0, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.4:
version "4.17.5" version "4.17.5"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511"
@ -5873,6 +5877,10 @@ vue-loader@^14.2.2:
vue-style-loader "^4.0.1" vue-style-loader "^4.0.1"
vue-template-es2015-compiler "^1.6.0" vue-template-es2015-compiler "^1.6.0"
vue-progressbar@^0.7.4:
version "0.7.4"
resolved "https://registry.yarnpkg.com/vue-progressbar/-/vue-progressbar-0.7.4.tgz#435dd9cb3707e29a1b18063afed44aeff371e606"
vue-router@^3.0.1: vue-router@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.1.tgz#d9b05ad9c7420ba0f626d6500d693e60092cc1e9" resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.1.tgz#d9b05ad9c7420ba0f626d6500d693e60092cc1e9"