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 :set_task_list
before_action :set_task_item, only: [:update, :destroy]
before_action :set_task_item, only: [:update]
def create
@task_item = TaskItem.new(task_item_params)
@ -24,7 +24,23 @@ class TaskItemsController < ApplicationController
end
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
end

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<template>
<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>
<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>
<table class="table is-fullwidth">
<table class="table is-fullwidth" :class="{ small: mediaQueries.touch }">
<thead>
<tr>
<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><app-date-time :date-time="r.created_at" :show-time="false"></app-date-time></td>
<td>
<div class="field is-grouped">
<div class="control">
<router-link v-if="isLoggedIn" :to="{name: 'new_log', params: { recipeId: r.id } }" class="button is-primary">
<app-icon icon="star" size="md"></app-icon>
<app-dropdown hover v-if="isLoggedIn" class="is-right">
<button slot="button" class="button">
<app-icon icon="menu"></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>
</div>
<div class="control">
<router-link v-if="isLoggedIn" :to="{name: 'edit_recipe', params: { id: r.id } }" class="button is-primary">
<app-icon icon="pencil" size="md"></app-icon>
<div class="dropdown-item">
<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>
</div>
<div class="control">
<button v-if="isLoggedIn" type="button" class="button is-danger" @click="deleteRecipe(r)">
<app-icon icon="x" size="md"></app-icon>
<div class="dropdown-item">
<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>
</div>
</div>
</app-dropdown>
</td>
</tr>
</tbody>
@ -85,6 +92,7 @@
import api from "../lib/Api";
import debounce from "lodash/debounce";
import { mapState } from "vuex";
export default {
data() {
@ -103,6 +111,9 @@
},
computed: {
...mapState([
"mediaQueries"
]),
recipes() {
if (this.recipeData) {
return this.recipeData.recipes;
@ -227,4 +238,17 @@
.recipe-time {
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>

View File

@ -1,6 +1,6 @@
<template>
<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">
@ -11,10 +11,19 @@
<task-list-mini-form :task-list="newList" :validation-errors="newListValidationErrors" @save="saveNewList"></task-list-mini-form>
</div>
</app-dropdown>
<br><br>
<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>
</div>
</div>
@ -64,7 +73,9 @@
...mapActions([
'refreshTaskLists',
'createTaskList',
'deleteTaskList'
'deleteTaskList',
'deleteTaskItems',
'completeTaskItems'
]),
...mapMutations([
'setCurrentTaskList'
@ -88,6 +99,46 @@
this.loadResource(
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);
}
deleteTaskItem(listId, taskItem) {
return this.del(`/task_lists/${listId}/task_items/${taskItem.id}`);
deleteTaskItems(listId, taskItems) {
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() {

View File

@ -97,14 +97,26 @@ export default new Vuex.Store({
}
},
removeTaskItem(state, item) {
const listId = item.task_list_id;
removeTaskItems(state, payload) {
const listId = payload.taskList.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);
list.task_items = list.task_items.filter(item => {
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) {
return api.deleteTaskItem(taskItem.task_list_id, taskItem)
.then(() => commit("removeTaskItem", taskItem));
deleteTaskItems({commit}, payload) {
return api.deleteTaskItems(payload.taskList.id, payload.taskItems)
.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
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 = [
"/"
@ -27,7 +28,7 @@ self.addEventListener('activate', function(event) {
caches.keys().then(function (keyList) {
return Promise.all(keyList.map(function (key) {
if (key !== cacheName) {
console.log('[ServiceWorker] Removing old cache', key);
console.log(`[ServiceWorker] Removing old cache: ${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
if (event.request.method !== 'GET' || event.request.url.indexOf('http') !== 0) {
return fetch(event.request);
return;
}
// Cache-first response for static assets

View File

@ -34,7 +34,12 @@ Rails.application.routes.draw do
end
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
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 "updated_at", null: false
t.text "preparation"
t.integer "recipe_as_ingredient_id"
t.index ["recipe_id"], name: "index_recipe_ingredients_on_recipe_id"
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
name "MyString"
quantity "MyString"
completed false
end
end

View File

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

View File

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