task lists

This commit is contained in:
Dan Elbert 2018-09-07 21:56:13 -05:00
parent 18603dc783
commit b1e5c22101
16 changed files with 288 additions and 56 deletions

View File

@ -2,7 +2,7 @@ class TaskItemsController < ApplicationController
before_action :ensure_valid_user before_action :ensure_valid_user
before_action :set_task_list before_action :set_task_list
before_action :set_task_item, only: [:update, :destroy] before_action :set_task_item, only: [:update]
def create def create
@task_item = TaskItem.new(task_item_params) @task_item = TaskItem.new(task_item_params)
@ -24,7 +24,23 @@ class TaskItemsController < ApplicationController
end end
def destroy def destroy
@task_item.destroy ids = Array.wrap(params[:id]) + Array.wrap(params[:ids])
TaskItem.transaction do
@task_items = @task_list.task_items.find(ids)
@task_items.each { |i| i.destroy }
end
head :no_content
end
def complete
ids = Array.wrap(params[:id]) + Array.wrap(params[:ids])
new_status = !params[:invert].present?
TaskItem.transaction do
@task_items = @task_list.task_items.find(ids)
@task_items.each { |i| i.update_attribute(:completed, new_status) }
end
head :no_content head :no_content
end end

View File

@ -1,10 +1,10 @@
<template> <template>
<div class="dropdown" :class="{'is-active': open}"> <div class="dropdown" :class="{'is-active': open, 'is-hoverable': hover}">
<div class="dropdown-trigger"> <div class="dropdown-trigger">
<slot name="button"> <slot name="button">
<button type="button" class="button" :class="buttonClass" @click="toggle"> <button type="button" class="button" :class="buttonClass" @click="toggle">
<span>{{ label }}</span> <span>{{ label }}</span>
<app-icon icon="caret-bottom"></app-icon> <app-icon icon="caret-bottom" size="xs"></app-icon>
</button> </button>
</slot> </slot>
</div> </div>
@ -24,8 +24,15 @@
export default { export default {
props: { props: {
open: { open: {
required: true, required: false,
type: Boolean type: Boolean,
default: false
},
hover: {
required: false,
type: Boolean,
default: false
}, },
label: { label: {

View File

@ -1,6 +1,6 @@
<template> <template>
<span class="icon" :class="sizeClass" @click="$emit('click', $event)"> <span class="icon" :class="sizeClass" @click="$emit('click', $event)">
<img ref="img" :class="['iconic', 'iconic-fluid', size]" :data-src="iconUrl" v-bind="extraIconAttributes" :style="{padding: svgPadding}" /> <img ref="img" :class="['iconic', 'iconic-fluid', size]" v-bind="extraIconAttributes" :style="{padding: svgPadding}" />
</span> </span>
</template> </template>
@ -13,6 +13,7 @@
import CircleCheck from "../iconic/svg/smart/circle-check"; import CircleCheck from "../iconic/svg/smart/circle-check";
import Link from "../iconic/svg/smart/link"; import Link from "../iconic/svg/smart/link";
import Lock from "../iconic/svg/smart/lock"; import Lock from "../iconic/svg/smart/lock";
import Menu from "../iconic/svg/smart/menu";
import Person from "../iconic/svg/smart/person"; import Person from "../iconic/svg/smart/person";
import Pencil from "../iconic/svg/smart/pencil"; import Pencil from "../iconic/svg/smart/pencil";
import Star from "../iconic/svg/smart/star"; import Star from "../iconic/svg/smart/star";
@ -26,7 +27,8 @@
class IconData { class IconData {
constructor(url, dataAttributes) { constructor(url, dataAttributes) {
this.url = url; this.url = url;
this.dataAttributes = dataAttributes || []; this.dataAttributes = dataAttributes || {};
this.dataAttributes['src'] = url;
} }
} }
@ -46,6 +48,7 @@
'link-intact': new IconData(Link, {state: 'intact'}), 'link-intact': new IconData(Link, {state: 'intact'}),
'lock-locked': new IconData(Lock, {state: 'locked'}), 'lock-locked': new IconData(Lock, {state: 'locked'}),
'lock-unlocked': new IconData(Lock, {state: 'unlocked'}), 'lock-unlocked': new IconData(Lock, {state: 'unlocked'}),
menu: new IconData(Menu),
pencil: new IconData(Pencil), pencil: new IconData(Pencil),
person: new IconData(Person), person: new IconData(Person),
star: new IconData(Star), star: new IconData(Star),
@ -54,6 +57,7 @@
}; };
const sizeMap = { const sizeMap = {
xs: new SizeData('is-small', '25%'),
sm: new SizeData('is-small'), sm: new SizeData('is-small'),
md: new SizeData(''), md: new SizeData(''),
lg: new SizeData('is-medium'), lg: new SizeData('is-medium'),
@ -93,10 +97,6 @@
return sizeMap[this.size]; return sizeMap[this.size];
}, },
iconUrl() {
return this.iconData.url;
},
extraIconAttributes() { extraIconAttributes() {
const attrs = {}; const attrs = {};
@ -117,18 +117,22 @@
mounted() { mounted() {
const self = this; const self = this;
setTimeout(() => {
iconicInstance.inject(this.$refs.img, { iconicInstance.inject(this.$refs.img, {
each: function(svg) { self.injectedSvg = svg; } each: function(svg) { self.injectedSvg = svg; }
}); });
});
}, },
updated() { updated() {
if (this.injectedSvg) {
for(let attr in this.extraIconAttributes) { for(let attr in this.extraIconAttributes) {
this.injectedSvg.setAttribute(attr, this.extraIconAttributes[attr]); this.injectedSvg.setAttribute(attr, this.extraIconAttributes[attr]);
} }
iconicInstance.update(this.injectedSvg); iconicInstance.update(this.injectedSvg);
} }
} }
}
</script> </script>

View File

@ -21,6 +21,7 @@
<td> <td>
<div class="check"> <div class="check">
<app-icon v-if="i.completed" icon="check"></app-icon> <app-icon v-if="i.completed" icon="check"></app-icon>
<span class="icon" v-else></span>
</div> </div>
</td> </td>
<td>{{ i.name }}</td> <td>{{ i.name }}</td>
@ -92,7 +93,8 @@
methods: { methods: {
...mapActions([ ...mapActions([
'createTaskItem', 'createTaskItem',
'updateTaskItem' 'updateTaskItem',
'completeTaskItems'
]), ]),
save() { save() {
@ -107,11 +109,13 @@
}, },
toggleItem(i) { toggleItem(i) {
const item = cloneDeep(i);
item.completed = !item.completed;
this.loadResource( this.loadResource(
this.updateTaskItem(item) this.completeTaskItems({
) taskList: this.taskList,
taskItems: [i],
completed: !i.completed
})
);
}, },
toggleShowAddItem() { toggleShowAddItem() {
@ -130,6 +134,8 @@
<style lang="scss" scoped> <style lang="scss" scoped>
@import "../styles/variables";
.columns { .columns {
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;
@ -142,4 +148,8 @@
margin-bottom: 0; margin-bottom: 0;
} }
div.check .icon {
border: 2px solid $link;
}
</style> </style>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="dropdown-item" @mouseover="hovering = true" @mouseleave="hovering = false" :class="{hovered: hovering, 'is-active': active}" @click="selectList"> <div class="dropdown-item" @mouseover="hovering = true" @mouseleave="hovering = false" :class="{hovered: hovering, 'is-active': active}" @click="selectList">
<span>{{taskList.name}}</span> <span>{{taskList.name}} ({{ taskList.task_items.length }})</span>
<button @click.stop="confirmingDelete = true" class="button is-small is-danger is-pulled-right"><app-icon icon="x" size="sm"></app-icon></button> <button @click.stop="confirmingDelete = true" class="button is-small is-danger is-pulled-right"><app-icon icon="x" size="sm"></app-icon></button>
<div class="is-clearfix"></div> <div class="is-clearfix"></div>

View File

@ -6,7 +6,7 @@
<app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="recipe" @changePage="changePage"></app-pager> <app-pager :current-page="currentPage" :total-pages="totalPages" paged-item-name="recipe" @changePage="changePage"></app-pager>
<table class="table is-fullwidth"> <table class="table is-fullwidth" :class="{ small: mediaQueries.touch }">
<thead> <thead>
<tr> <tr>
<th v-for="h in tableHeader" :key="h.name"> <th v-for="h in tableHeader" :key="h.name">
@ -52,23 +52,30 @@
<td class="recipe-time">{{ formatRecipeTime(r.total_time, r.active_time) }}</td> <td class="recipe-time">{{ formatRecipeTime(r.total_time, r.active_time) }}</td>
<td><app-date-time :date-time="r.created_at" :show-time="false"></app-date-time></td> <td><app-date-time :date-time="r.created_at" :show-time="false"></app-date-time></td>
<td> <td>
<div class="field is-grouped"> <app-dropdown hover v-if="isLoggedIn" class="is-right">
<div class="control"> <button slot="button" class="button">
<router-link v-if="isLoggedIn" :to="{name: 'new_log', params: { recipeId: r.id } }" class="button is-primary"> <app-icon icon="menu"></app-icon>
<app-icon icon="star" size="md"></app-icon> </button>
<div class="dropdown-item">
<router-link :to="{name: 'new_log', params: { recipeId: r.id } }" class="button is-primary is-fullwidth">
<app-icon icon="star" size="md"></app-icon> <span>Add Log Entry</span>
</router-link> </router-link>
</div> </div>
<div class="control">
<router-link v-if="isLoggedIn" :to="{name: 'edit_recipe', params: { id: r.id } }" class="button is-primary"> <div class="dropdown-item">
<app-icon icon="pencil" size="md"></app-icon> <router-link :to="{name: 'edit_recipe', params: { id: r.id } }" class="button is-primary is-fullwidth">
<app-icon icon="pencil" size="md"></app-icon> <span>Edit Recipe</span>
</router-link> </router-link>
</div> </div>
<div class="control">
<button v-if="isLoggedIn" type="button" class="button is-danger" @click="deleteRecipe(r)"> <div class="dropdown-item">
<app-icon icon="x" size="md"></app-icon> <button type="button" class="button is-danger is-fullwidth" @click="deleteRecipe(r)">
<app-icon icon="x" size="md"></app-icon> <span>Delete Recipe</span>
</button> </button>
</div> </div>
</div>
</app-dropdown>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -85,6 +92,7 @@
import api from "../lib/Api"; import api from "../lib/Api";
import debounce from "lodash/debounce"; import debounce from "lodash/debounce";
import { mapState } from "vuex";
export default { export default {
data() { data() {
@ -103,6 +111,9 @@
}, },
computed: { computed: {
...mapState([
"mediaQueries"
]),
recipes() { recipes() {
if (this.recipeData) { if (this.recipeData) {
return this.recipeData.recipes; return this.recipeData.recipes;
@ -227,4 +238,17 @@
.recipe-time { .recipe-time {
white-space: nowrap; white-space: nowrap;
} }
.table th {
white-space: nowrap;
}
.table.small {
td, th {
&:nth-of-type(3), &:nth-of-type(4), &:nth-of-type(5), &:nth-of-type(6) {
display: none;
}
}
}
</style> </style>

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<h1>Tasks</h1> <h1 class="title is-3">Tasks</h1>
<app-dropdown button-class="is-primary" :open="showListDropdown" :label="listSelectLabel" @open="showListDropdown = true" @close="showListDropdown = false"> <app-dropdown button-class="is-primary" :open="showListDropdown" :label="listSelectLabel" @open="showListDropdown = true" @close="showListDropdown = false">
@ -11,10 +11,19 @@
<task-list-mini-form :task-list="newList" :validation-errors="newListValidationErrors" @save="saveNewList"></task-list-mini-form> <task-list-mini-form :task-list="newList" :validation-errors="newListValidationErrors" @save="saveNewList"></task-list-mini-form>
</div> </div>
</app-dropdown> </app-dropdown>
<br><br>
<div v-if="currentTaskList !== null"> <div v-if="currentTaskList !== null">
<div class="box">
<button class="button" @click="deleteCompletedItems">Clear Completed</button>
<button class="button" @click="completeAllItems">Check All</button>
<button class="button" @click="unCompleteAllItems">Uncheck All</button>
</div>
<div class="box">
<task-item-list :task-list="currentTaskList"></task-item-list> <task-item-list :task-list="currentTaskList"></task-item-list>
</div>
</div> </div>
@ -64,7 +73,9 @@
...mapActions([ ...mapActions([
'refreshTaskLists', 'refreshTaskLists',
'createTaskList', 'createTaskList',
'deleteTaskList' 'deleteTaskList',
'deleteTaskItems',
'completeTaskItems'
]), ]),
...mapMutations([ ...mapMutations([
'setCurrentTaskList' 'setCurrentTaskList'
@ -88,6 +99,46 @@
this.loadResource( this.loadResource(
this.deleteTaskList(list) this.deleteTaskList(list)
); );
},
completeAllItems() {
const toComplete = this.currentTaskList.task_items.filter(i => !i.completed);
this.loadResource(
this.completeTaskItems({
taskList: this.currentTaskList,
taskItems: toComplete,
completed: true
})
)
},
unCompleteAllItems() {
const toUnComplete = this.currentTaskList.task_items.filter(i => i.completed);
this.loadResource(
this.completeTaskItems({
taskList: this.currentTaskList,
taskItems: toUnComplete,
completed: false
})
)
},
deleteCompletedItems() {
this.loadResource(
this.deleteTaskItems({
taskList: this.currentTaskList,
taskItems: this.currentTaskList.task_items.filter(i => i.completed)
})
);
},
deleteAllItems() {
this.loadResource(
this.deleteTaskItems({
taskList: this.currentTaskList,
taskItems: this.currentTaskList.task_items
})
);
} }
}, },

View File

@ -403,8 +403,23 @@ class Api {
return this.patch(`/task_lists/${listId}/task_items/${taskItem.id}`, params); return this.patch(`/task_lists/${listId}/task_items/${taskItem.id}`, params);
} }
deleteTaskItem(listId, taskItem) { deleteTaskItems(listId, taskItems) {
return this.del(`/task_lists/${listId}/task_items/${taskItem.id}`); const params = {
ids: taskItems.map(i => i.id)
};
return this.del(`/task_lists/${listId}/task_items/`, params);
}
completeTaskItems(listId, taskItems, invert = false) {
const params = {
ids: taskItems.map(i => i.id)
};
if (invert === true) {
params.invert = true;
}
return this.patch(`/task_lists/${listId}/task_items/complete`, params);
} }
getAdminUserList() { getAdminUserList() {

View File

@ -97,14 +97,26 @@ export default new Vuex.Store({
} }
}, },
removeTaskItem(state, item) { removeTaskItems(state, payload) {
const listId = item.task_list_id; const listId = payload.taskList.id;
const list = state.taskLists.find(l => l.id === listId); const list = state.taskLists.find(l => l.id === listId);
if (list) { if (list) {
const taskIdx = list.task_items.findIndex(i => i.id === item.id);
if (taskIdx >= 0) { list.task_items = list.task_items.filter(item => {
list.task_items.splice(taskIdx, 1); return payload.taskItems.findIndex(i => i.id === item.id) === -1;
});
} }
},
setTaskItemCompletion(state, payload) {
const listId = payload.taskList.id;
const list = state.taskLists.find(l => l.id === listId);
if (list) {
list.task_items.forEach(item => {
if (payload.taskItems.findIndex(i => i.id === item.id) >= 0) {
item.completed = payload.completed;
}
});
} }
} }
}, },
@ -182,9 +194,14 @@ export default new Vuex.Store({
}); });
}, },
deleteTaskItem({commit}, taskItem) { deleteTaskItems({commit}, payload) {
return api.deleteTaskItem(taskItem.task_list_id, taskItem) return api.deleteTaskItems(payload.taskList.id, payload.taskItems)
.then(() => commit("removeTaskItem", taskItem)); .then(() => commit("removeTaskItems", payload));
},
completeTaskItems({commit}, payload) {
return api.completeTaskItems(payload.taskList.id, payload.taskItems, !payload.completed)
.then(() => commit("setTaskItemCompletion", payload));
} }
} }
}); });

View File

@ -1,10 +1,11 @@
<% <%
manifest_data = Webpacker::manifest.refresh 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? } manifest_timestamp = File.mtime(Webpacker::config.public_manifest_path).to_i
pack_assets = manifest_data.values.select { |asset| asset !~ /\.map$/ }
%> %>
var cacheName = "parsley-cache-<%= File.mtime(Webpacker::config.public_manifest_path).to_i %>"; var cacheName = "parsley-cache-<%= manifest_timestamp %>";
var staticAssets = [ var staticAssets = [
"/" "/"
@ -27,7 +28,7 @@ self.addEventListener('activate', function(event) {
caches.keys().then(function (keyList) { caches.keys().then(function (keyList) {
return Promise.all(keyList.map(function (key) { return Promise.all(keyList.map(function (key) {
if (key !== cacheName) { if (key !== cacheName) {
console.log('[ServiceWorker] Removing old cache', key); console.log(`[ServiceWorker] Removing old cache: ${key}`);
return caches.delete(key); return caches.delete(key);
} }
})); }));
@ -42,7 +43,7 @@ self.addEventListener('fetch', function(event) {
// Any non-GET or non-http(s) request should be ignored // Any non-GET or non-http(s) request should be ignored
if (event.request.method !== 'GET' || event.request.url.indexOf('http') !== 0) { if (event.request.method !== 'GET' || event.request.url.indexOf('http') !== 0) {
return fetch(event.request); return;
} }
// Cache-first response for static assets // Cache-first response for static assets

View File

@ -34,7 +34,12 @@ Rails.application.routes.draw do
end end
resources :task_lists, only: [:index, :show, :create, :update, :destroy] do resources :task_lists, only: [:index, :show, :create, :update, :destroy] do
resources :task_items, only: [:create, :update, :destroy] resources :task_items, only: [:create, :update] do
collection do
delete '/', action: :destroy, as: :destroy
patch 'complete', action: :complete
end
end
end end
resource :user, only: [:new, :create, :edit, :update] resource :user, only: [:new, :create, :edit, :update]

View File

@ -83,6 +83,7 @@ ActiveRecord::Schema.define(version: 2018_09_06_191333) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.text "preparation" t.text "preparation"
t.integer "recipe_as_ingredient_id"
t.index ["recipe_id"], name: "index_recipe_ingredients_on_recipe_id" t.index ["recipe_id"], name: "index_recipe_ingredients_on_recipe_id"
end end

View File

@ -0,0 +1,80 @@
require 'rails_helper'
RSpec.describe TaskItemsController, type: :controller do
render_views
before(:each) do
request.accept = "application/json"
end
let(:user) {
create(:user)
}
let(:task_list) { create(:task_list, user: user) }
let(:valid_session) { {user_id: user.id} }
describe 'POST #create' do
it 'creates a task_item' do
expect do
post :create, params: {task_list_id: task_list.id, task_item: {name: 'name'}}, session: valid_session
end.to change(TaskItem, :count).by 1
end
end
describe 'PATCH #update' do
it 'updates a task_item' do
i = create(:task_item, task_list: task_list)
patch :update, params: {task_list_id: task_list.id, id: i.id, task_item: {name: 'new name'}}, session: valid_session
i.reload
expect(i.name).to eq 'new name'
end
end
describe 'DELETE #destroy' do
it 'destroys the item' do
i = create(:task_item, task_list: task_list)
delete :destroy, params: {task_list_id: task_list.id, id: i.id}, session: valid_session
expect(TaskItem.where(id: i.id).count).to eq 0
end
it 'destroys an array of items' do
i1 = create(:task_item, task_list: task_list)
i2 = create(:task_item, task_list: task_list)
i3 = create(:task_item, task_list: task_list)
delete :destroy, params: {task_list_id: task_list.id, ids: [i1.id, i2.id, i3.id]}, session: valid_session
expect(TaskItem.where(id: [i1.id, i2.id, i3.id]).count).to eq 0
end
end
describe 'PATCH complete' do
it 'sets all given items to completed' do
i1 = create(:task_item, task_list: task_list)
i2 = create(:task_item, task_list: task_list)
i3 = create(:task_item, task_list: task_list)
patch :complete, params: {task_list_id: task_list.id, ids: [i1.id, i2.id, i3.id]}, session: valid_session
[i1, i2, i3].each do |i|
i.reload
expect(i.completed).to be_truthy
end
end
it 'sets all given items to not completed' do
i1 = create(:task_item, task_list: task_list)
i2 = create(:task_item, task_list: task_list)
i3 = create(:task_item, task_list: task_list)
patch :complete, params: {task_list_id: task_list.id, ids: [i1.id, i2.id, i3.id], invert: true}, session: valid_session
[i1, i2, i3].each do |i|
i.reload
expect(i.completed).to be_falsey
end
end
end
end

View File

@ -3,5 +3,6 @@ FactoryBot.define do
task_list task_list
name "MyString" name "MyString"
quantity "MyString" quantity "MyString"
completed false
end end
end end

View File

@ -1,5 +1,5 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe TaskItem, type: :model do RSpec.describe TaskItem, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end end

View File

@ -1,5 +1,5 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe TaskList, type: :model do RSpec.describe TaskList, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end end