tasks
Some checks failed
parsley/pipeline/head There was a failure building this commit

This commit is contained in:
Dan Elbert 2018-09-06 18:16:13 -05:00
parent 4b25f753f1
commit 18603dc783
29 changed files with 873 additions and 561 deletions

View File

@ -9,7 +9,7 @@ class TaskItemsController < ApplicationController
@task_item.task_list = @task_list
if @task_item.save
render :show, status: :created, location: @task_item
render :show, status: :created, location: [@task_list, @task_item]
else
render json: @task_item.errors, status: :unprocessable_entity
end
@ -17,7 +17,7 @@ class TaskItemsController < ApplicationController
def update
if @task_item.update(task_item_params)
render :show, status: :ok, location: @task_item
render :show, status: :ok, location: [@task_list, @task_item]
else
render json: @task_item.errors, status: :unprocessable_entity
end
@ -31,7 +31,7 @@ class TaskItemsController < ApplicationController
private
def task_item_params
params.require(:task_item).permit(:name, :quantity)
params.require(:task_item).permit(:name, :quantity, :completed)
end
def set_task_list

View File

@ -4,7 +4,7 @@ class TaskListsController < ApplicationController
before_action :set_task_list, only: [:show, :update, :destroy]
def index
@task_lists = TaskList.for_user(current_user).order(created_at: :desc)
@task_lists = TaskList.for_user(current_user).includes(:task_items).order(created_at: :desc)
end
def show

View File

@ -46,27 +46,7 @@
created() {
if (this.user === null && this.authChecked === false) {
this.checkAuthentication();
}
// 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

@ -6,7 +6,7 @@
<header class="modal-card-head">
<slot name="title">
<p class="modal-card-title">{{ title }}</p>
<app-icon icon="x" aria-label="close" @click="close"></app-icon>
<app-icon class="close-button" icon="x" aria-label="close" @click="close"></app-icon>
</slot>
</header>
@ -36,7 +36,7 @@
},
mounted() {
document.body.appendChild(this.$refs.modal);
this.$root.$el.appendChild(this.$refs.modal);
},
beforeDestroy() {
@ -63,6 +63,8 @@
<style lang="scss" scoped>
.close-button {
cursor: pointer;
}
</style>

View File

@ -17,7 +17,7 @@
<div class="field">
<div class="control">
<button class="button is-primary">Add</button>
<button class="button is-primary" @click="save">Add</button>
</div>
</div>
@ -45,11 +45,15 @@
save() {
this.$emit("save", this.taskItem);
},
focus() {
this.$refs.nameInput.focus();
}
},
mounted() {
this.$refs.nameInput.focus();
this.focus();
}
}

View File

@ -2,10 +2,10 @@
<div>
<app-expand-transition name="fade">
<task-item-edit :task-item="newItem" v-if="showAddItem"></task-item-edit>
<task-item-edit @save="save" :task-item="newItem" v-if="showAddItem" ref="itemEdit"></task-item-edit>
</app-expand-transition>
<table class="table is-narrow">
<table class="table">
<thead>
<tr>
<th></th>
@ -17,8 +17,12 @@
</tr>
</thead>
<tbody>
<tr v-for="i in taskItems">
<td></td>
<tr v-for="i in taskItems" :key="i.id" @click="toggleItem(i)">
<td>
<div class="check">
<app-icon v-if="i.completed" icon="check"></app-icon>
</div>
</td>
<td>{{ i.name }}</td>
<td>{{ i.quantity }}</td>
<td></td>
@ -36,40 +40,83 @@
<script>
import * as Errors from '../lib/Errors';
import { mapActions } from "vuex";
import cloneDeep from "lodash/cloneDeep";
import TaskItemEdit from "./TaskItemEdit";
const newItemTemplate = function() {
const newItemTemplate = function(listId) {
return {
task_list_id: listId,
name: '',
quantity: ''
quantity: '',
completed: false
};
};
export default {
props: {
taskItems: {
taskList: {
required: true,
type: Array
type: Object
}
},
data() {
return {
showAddItem: false,
newItem: newItemTemplate(),
newItem: null,
newItemValidationErrors: {}
};
},
computed: {
taskItems() {
const top = [];
const bottom = [];
const list = (this.taskList ? this.taskList.task_items : []);
for (let i of list) {
if (!i.completed) {
top.push(i);
} else {
bottom.push(i);
}
}
return top.concat(bottom);
}
},
methods: {
...mapActions([
'createTaskItem',
'updateTaskItem'
]),
save() {
this.loadResource(
this.createTaskItem(this.newItem)
.then(() => {
this.newItem = newItemTemplate(this.taskList.id);
this.$refs.itemEdit.focus();
})
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.newItemValidationErrors = err.validationErrors()))
)
},
toggleItem(i) {
const item = cloneDeep(i);
item.completed = !item.completed;
this.loadResource(
this.updateTaskItem(item)
)
},
toggleShowAddItem() {
this.newItem = newItemTemplate(this.taskList.id);
this.showAddItem = !this.showAddItem;
if (this.showAddItem) {
this.$nextTick(() => {
//this.$refs.quantityInput.focus();
});
}
}
},

View File

@ -4,7 +4,7 @@
<app-dropdown button-class="is-primary" :open="showListDropdown" :label="listSelectLabel" @open="showListDropdown = true" @close="showListDropdown = false">
<task-list-dropdown-item v-for="l in taskLists" :task-list="l" :active="currentList !== null && currentList.id === l.id" @select="selectList" @delete="deleteList"></task-list-dropdown-item>
<task-list-dropdown-item v-for="l in taskLists" :key="l.id" :task-list="l" :active="currentTaskList !== null && currentTaskList.id === l.id" @select="selectList" @delete="deleteList"></task-list-dropdown-item>
<hr class="dropdown-divider" v-if="taskLists.length > 0">
<div class="dropdown-item">
@ -12,9 +12,9 @@
</div>
</app-dropdown>
<div v-if="currentList !== null">
<div v-if="currentTaskList !== null">
<task-item-list :task-items="currentList.task_items"></task-item-list>
<task-item-list :task-list="currentTaskList"></task-item-list>
</div>
@ -25,6 +25,8 @@
import api from "../lib/Api";
import * as Errors from '../lib/Errors';
import { mapActions, mapMutations, mapState } from "vuex";
import TaskListMiniForm from "./TaskListMiniForm";
import TaskListDropdownItem from "./TaskListDropdownItem";
import TaskItemList from "./TaskItemList";
@ -38,66 +40,61 @@
export default {
data() {
return {
taskLists: [],
showListDropdown: false,
currentList: null,
newList: newListTemplate(),
newListValidationErrors: {}
}
},
computed: {
...mapState([
'taskLists',
'currentTaskList'
]),
listSelectLabel() {
if (this.currentList === null) {
if (this.currentTaskList === null) {
return "Select or Create a List";
} else {
return this.currentList.name;
return this.currentTaskList.name;
}
}
},
methods: {
...mapActions([
'refreshTaskLists',
'createTaskList',
'deleteTaskList'
]),
...mapMutations([
'setCurrentTaskList'
]),
selectList(list) {
this.currentList = list;
this.setCurrentTaskList(list);
this.showListDropdown = false;
},
saveNewList() {
this.loadResource(
api.postTaskList(this.newList)
.then(l => this.currentList = l)
this.createTaskList(this.newList)
.then(() => this.showListDropdown = false)
.then(() => this.updateData())
.then(() => { this.newList = newListTemplate(); this.newListValidationErrors = {}; } )
.catch(Errors.onlyFor(Errors.ApiValidationError, err => this.newListValidationErrors = err.validationErrors()))
);
},
updateData() {
return this.loadResource(api.getTaskLists(data => this.setNewData(data)));
},
setNewData(list) {
this.taskLists = list;
if (this.currentList !== null) {
this.currentList = this.taskLists.find(l => this.currentList.id === l.id) || null;
}
if (this.currentList === null && this.taskLists.length > 0) {
this.currentList = this.taskLists[0];
}
},
deleteList(list) {
this.loadResource(
api.deleteTaskList(list)
.then(() => this.updateData())
this.deleteTaskList(list)
);
}
},
created() {
this.updateData()
this.loadResource(
this.refreshTaskLists()
);
},
components: {

View File

@ -7,10 +7,10 @@
<app-modal title="Login" :open="showLogin" @dismiss="showLogin = false">
<div>
<form @submit.prevent="login">
<form @submit.prevent="performLogin">
<div v-if="error" class="notification is-danger">
{{error}}
<div v-if="loginMessage" class="notification is-danger">
{{loginMessage}}
</div>
<div class="field">
@ -48,7 +48,7 @@
<script>
import api from "../lib/Api";
import { mapMutations } from "vuex";
import { mapActions, mapState } from "vuex";
export default {
data() {
@ -61,14 +61,17 @@
},
computed: {
...mapState([
'loginMessage'
]),
enableSubmit() {
return this.username !== '' && this.password !== '' && !this.isLoading;
}
},
methods: {
...mapMutations([
'setUser'
...mapActions([
'login'
]),
openDialog() {
@ -76,16 +79,18 @@
this.$nextTick(() => this.$refs.usernameInput.focus());
},
login() {
performLogin() {
if (this.username !== '' && this.password !== '') {
this.loadResource(api.postLogin(this.username, this.password).then(data => {
if (data.success) {
this.setUser(data.user);
this.showLogin = false;
} else {
this.error = data.message;
}
}));
const params = {username: this.username, password: this.password};
this.loadResource(
this.login(params)
.then(data => {
if (data.success) {
this.showLogin = false;
}
})
);
}
}
},

View File

@ -387,13 +387,15 @@ class Api {
return {
task_item: {
name: taskItem.name,
quantity: taskItem.quantity
quantity: taskItem.quantity,
completed: taskItem.completed
}
}
}
postTaskItem(listId, taskItem) {
return this.post(`/task_lists/${listId}/task_items`)
const params = this.buildTaskItemParams(taskItem);
return this.post(`/task_lists/${listId}/task_items`, params);
}
patchTaskItem(listId, taskItem) {

View File

@ -1,6 +1,6 @@
import Vue from 'vue';
import { mapGetters, mapMutations, mapState } from 'vuex';
import { mapActions, mapGetters, mapMutations, mapState } from 'vuex';
import api from "../lib/Api";
Vue.mixin({
@ -23,10 +23,12 @@ Vue.mixin({
}
},
methods: {
...mapActions([
'updateCurrentUser'
]),
...mapMutations([
'setError',
'setLoading',
'setUser'
'setLoading'
]),
loadResource(promise) {
@ -43,7 +45,7 @@ Vue.mixin({
},
checkAuthentication() {
return this.loadResource(api.getCurrentUser().then(user => this.setUser(user)));
return this.loadResource(this.updateCurrentUser());
}
}
});

View File

@ -0,0 +1,44 @@
// Adds a module to a vuex store with a set of media query states
const defaultOptions = {
module: "mediaQueries"
};
// 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)"
};
export default function(store, options) {
let opts = Object.assign({}, defaultOptions, options || {});
const moduleName = opts.module;
const initialState = {};
for (let device in mediaQueries) {
const query = window.matchMedia(mediaQueries[device]);
query.onchange = (q) => {
store.commit(moduleName + "/MEDIA_QUERY_CHANGED", {mediaName: device, value: q.matches});
};
initialState[device] = query.matches;
}
store.registerModule(moduleName, {
namespaced: true,
state: initialState,
mutations: {
"MEDIA_QUERY_CHANGED" (state, data) {
state[data.mediaName] = data.value;
}
}
});
}

View File

@ -3,6 +3,7 @@ import '../styles';
import Vue from 'vue'
import { sync } from 'vuex-router-sync';
import { swInit } from "../lib/ServiceWorker";
import responsiveSync from "../lib/VuexResponsiveSync";
import VueProgressBar from "vue-progressbar";
import config from '../config';
import store from '../store';
@ -57,6 +58,7 @@ Vue.use(VueProgressBar, {
sync(store, router);
swInit(store);
responsiveSync(store);
document.addEventListener('DOMContentLoaded', () => {

View File

@ -13,6 +13,10 @@ export default new Vuex.Store({
error: null,
authChecked: false,
user: null,
loginMessage: null,
taskLists: [],
currentTaskList: null,
// MediaQueryList objects in the root App component maintain this state.
mediaQueries: {
@ -62,14 +66,125 @@ export default new Vuex.Store({
state.user = user;
},
setMediaQuery(state, data) {
state.mediaQueries[data.mediaName] = data.value;
setLoginMessage(state, msg) {
state.loginMessage = msg;
},
setTaskLists(state, lists) {
state.taskLists = lists || [];
},
setCurrentTaskList(state, list) {
state.currentTaskList = list || null;
},
appendTaskItem(state, item) {
const listId = item.task_list_id;
const list = state.taskLists.find(l => l.id === listId);
if (list) {
list.task_items.push(item);
}
},
replaceTaskItem(state, item) {
const listId = item.task_list_id;
const list = state.taskLists.find(l => l.id === listId);
if (list) {
const taskIdx = list.task_items.findIndex(i => i.id === item.id);
if (taskIdx >= 0) {
list.task_items.splice(taskIdx, 1, item);
}
}
},
removeTaskItem(state, item) {
const listId = item.task_list_id;
const list = state.taskLists.find(l => l.id === listId);
if (list) {
const taskIdx = list.task_items.findIndex(i => i.id === item.id);
if (taskIdx >= 0) {
list.task_items.splice(taskIdx, 1);
}
}
}
},
actions: {
updateCurrentUser({commit}) {
return api.getCurrentUser()
.then(user => {
commit("setUser", user);
return user;
});
},
login({commit}, authData) {
return api.postLogin(authData.username, authData.password)
.then(data => {
if (data.success) {
commit("setUser", data.user);
commit("setLoginMessage", null);
} else {
commit("setUser", null);
commit("setLoginMessage", data.message);
}
return data;
});
},
logout({commit}) {
return api.getLogout()
.then(() => commit("setUser", null));
.then(() => {
commit("setUser", null);
});
},
refreshTaskLists({commit, state}) {
const cb = function(data) {
commit("setTaskLists", data);
let ctl = null;
if (state.currentTaskList) {
ctl = data.find(l => l.id === state.currentTaskList.id);
}
ctl = ctl || data[0] || null;
commit("setCurrentTaskList", ctl);
};
return api.getTaskLists(cb)
},
createTaskList({commit, dispatch}, newList) {
return api.postTaskList(newList)
.then(data => commit("setCurrentTaskList", data))
.then(() => dispatch("refreshTaskLists"))
},
deleteTaskList({dispatch}, taskList) {
return api.deleteTaskList(taskList)
.then(() => dispatch("refreshTaskLists"));
},
createTaskItem({commit, dispatch}, taskItem) {
return api.postTaskItem(taskItem.task_list_id, taskItem)
.then(data => {
commit("appendTaskItem", data);
return data;
});
},
updateTaskItem({commit}, taskItem) {
return api.patchTaskItem(taskItem.task_list_id, taskItem)
.then(data => {
commit("replaceTaskItem", data);
return data;
});
},
deleteTaskItem({commit}, taskItem) {
return api.deleteTaskItem(taskItem.task_list_id, taskItem)
.then(() => commit("removeTaskItem", taskItem));
}
}
});

View File

@ -0,0 +1,95 @@
# == Default Values
#
# Allows default attributes values to be declared. The callback used to set the values can be controlled via options.
#
# The following option keys are allowed:
# `:on`. If omitted, it defaults to :initialize.
# :on may be one of the following: :initialize, :create, :update, :save
#
# `:empty`. If omitted, it defaults to only updating `nil` values
# :empty should be a lambda (or proc) that accepts the current value of the attribute and returns true if the value
# should be replaced by the default
#
# Examples:
# Sets `attr` to 'default' and `attr2` to 'default' any time the class is instantiated
# default_values {
# attr: 'default',
# attr2: 'default'
# }
#
# Sets `roles` to [:super_admin] only when saving a new object and only if roles is an empty array (it will not update a nil value)
# default_values({roles: [:super_admin]}, {on: :create, empty: ->(v) { v == [] }})
#
module DefaultValues
extend ActiveSupport::Concern
DEFAULT_OPTIONS = { on: :initialize }
DefaultValue = Struct.new(:value, :options)
included do
class_attribute :_default_values
self._default_values = {}
after_initialize :set_default_values_initialize
before_create :set_default_values_create
before_update :set_default_values_update
before_save :set_default_values_save
end
def set_default_values_initialize
set_default_values(:initialize)
end
def set_default_values_create
set_default_values(:create)
end
def set_default_values_update
set_default_values(:update)
end
def set_default_values_save
set_default_values(:save)
end
def set_default_values(on)
_default_values.each do |k, dv|
if dv.options[:on].to_sym == on
v = dv.value
v_lambda = Proc === v ? v : -> { v }
tester = dv.options[:empty] || ->(x) { x.nil? }
self.send("#{k}=", self.instance_exec(&v_lambda)) if self.respond_to?(k) && tester.call(self.send(k))
end
end
end
module ClassMethods
# Copy defaults on inheritance.
def inherited(base)
base._default_values = _default_values.dup
super
end
def default_values(defaults_hash, options = {})
options = DEFAULT_OPTIONS.merge(options.symbolize_keys)
valid_ons = [:initialize, :create, :update, :save]
unless valid_ons.include? options[:on]
raise "Invalid options[:on] value: [#{options[:on]}]. Must be one of these symbols: #{valid_ons.join(', ')}"
end
if options[:empty].present?
proc = options[:empty]
raise "Invalid options[:empty]. Must be a Proc or Lambda with an arity of 1" unless (proc.is_a?(Proc) && proc.arity == 1)
end
defaults_hash.each do |k, v|
_default_values[k] = DefaultValue.new(v, options)
end
end
end
end

View File

@ -134,8 +134,6 @@ class Recipe < ApplicationRecord
query = query.where(id: tags.joins(:recipes).pluck('recipes.id'))
end
puts criteria.inspect
query.page(criteria.page).per(criteria.per)
end

View File

@ -1,7 +1,10 @@
class TaskItem < ApplicationRecord
include DefaultValues
belongs_to :task_list
validates :name, presence: true
default_values completed: false
end

View File

@ -14,8 +14,6 @@ module ViewModels
self.send(setter, params[attr])
end
end
puts self.inspect
end
def sort_column

View File

@ -1,9 +1,10 @@
<%
pack_assets = [asset_pack_path("application.js"), asset_pack_path("application.css")].select { |a| a.present? }
manifest_data = Webpacker::manifest.refresh
pack_assets = manifest_data.values.select { |asset| asset !~ /\.map$/ } # [asset_pack_path("application.js"), asset_pack_path("application.css")].select { |a| a.present? }
%>
var cacheName = "parsley-cache-<%= File.mtime(Webpacker::manifest.config.public_manifest_path).to_i %>";
var cacheName = "parsley-cache-<%= File.mtime(Webpacker::config.public_manifest_path).to_i %>";
var staticAssets = [
"/"
@ -39,6 +40,11 @@ self.addEventListener('fetch', function(event) {
var isCacheThenNetwork = event.request.headers.get("Cache-Then-Network") === "true";
var x, asset;
// Any non-GET or non-http(s) request should be ignored
if (event.request.method !== 'GET' || event.request.url.indexOf('http') !== 0) {
return fetch(event.request);
}
// Cache-first response for static assets
for (x = 0; x < staticAssets.length; x++) {
asset = staticAssets[x];

View File

@ -1 +1 @@
json.extract! task_item, :id, :name, :quantity, :created_on, :updated_on
json.extract! task_item, :id, :task_list_id, :name, :quantity, :completed, :created_at, :updated_at

View File

@ -0,0 +1 @@
json.partial! 'task_items/task_item', task_item: @task_item

View File

@ -1,3 +1,3 @@
json.extract! task_list, :id, :name, :created_at, :updated_at
json.task_items task_list.task_items, partial: 'task_item/task_item', as: :task_item
json.task_items task_list.task_items, partial: 'task_items/task_item', as: :task_item

View File

@ -34,7 +34,7 @@ Rails.application.routes.draw do
end
resources :task_lists, only: [:index, :show, :create, :update, :destroy] do
resources :task_list_items, only: [:create, :update, :destroy]
resources :task_items, only: [:create, :update, :destroy]
end
resource :user, only: [:new, :create, :edit, :update]

View File

@ -3,7 +3,7 @@ const vue = require('./loaders/vue');
const svg = require('./loaders/svg');
environment.loaders.append('vue', vue);
//environment.loaders.append('svg', svg);
//environment.loaders.prepend('svg', svg);
//const fileLoader = environment.loaders.get('file');
//fileLoader.exclude = /\.(svg)$/i;

View File

@ -2,6 +2,9 @@
module.exports = {
test: /\.svg$/,
use: [{
loader: 'svg-loader'
loader: 'url-loader',
options: {
limit: 10000
}
}]
};

View File

@ -0,0 +1,13 @@
class UpdateTaskItems < ActiveRecord::Migration[5.2]
class TaskItemMigrator < ActiveRecord::Base
self.table_name = 'task_items'
end
def change
add_column :task_items, :completed, :boolean
TaskItemMigrator.reset_column_information
TaskItemMigrator.update_all(completed: false)
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2018_08_27_215102) do
ActiveRecord::Schema.define(version: 2018_09_06_191333) do
create_table "ingredient_units", force: :cascade do |t|
t.integer "ingredient_id", null: false
@ -132,6 +132,7 @@ ActiveRecord::Schema.define(version: 2018_08_27_215102) do
t.string "quantity"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "completed"
t.index ["task_list_id"], name: "index_task_items_on_task_list_id"
end

25
lib/tasks/dev.rake Normal file
View File

@ -0,0 +1,25 @@
namespace :dev do
desc 'Run both rails server and webpack dev server'
task :run do
pids = []
pids << fork do
exec 'rails s'
end
pids << fork do
exec 'bin/webpack-dev-server'
end
begin
Process.wait
rescue SignalException
puts 'shutting down...'
pids.each { |pid| Process.kill("SIGINT", pid) }
end
end
end

View File

@ -7,6 +7,7 @@
"css-loader": "^0.28.11",
"lodash": "^4.17.5",
"svg-loader": "^0.0.2",
"url-loader": "1.0.1",
"vue": "^2.5.16",
"vue-loader": "^14.2.2",
"vue-progressbar": "^0.7.4",

872
yarn.lock

File diff suppressed because it is too large Load Diff