diff --git a/.travis.yml b/.travis.yml index 954b46c..f49f0c8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: node_js sudo: false node_js: -- 6.6.0 +- 7.2.0 deploy: provider: heroku api_key: diff --git a/declarations/database/models.js b/declarations/database/models.js index b57b198..3f6e1d5 100644 --- a/declarations/database/models.js +++ b/declarations/database/models.js @@ -6,6 +6,7 @@ declare type Item = { starred: boolean; parent_id: number; user_id: number; + is_deleted: boolean; }; declare type User = { diff --git a/migrations/20161220192127-add-is-deleted-to-items.js b/migrations/20161220192127-add-is-deleted-to-items.js new file mode 100644 index 0000000..a21e088 --- /dev/null +++ b/migrations/20161220192127-add-is-deleted-to-items.js @@ -0,0 +1,11 @@ +'use strict'; + + +module.exports = { + up: function (queryInterface, Sequelize) { + queryInterface.addColumn( 'Items', 'is_deleted', { type: Sequelize.BOOLEAN, defaultValue: false, allowNull: false } ) + }, + down: function (queryInterface, Sequelize) { + queryInterface.removeColumn( 'Items', 'is_deleted') + } +}; diff --git a/models/item.js b/models/item.js index 38a86e1..e416749 100644 --- a/models/item.js +++ b/models/item.js @@ -10,7 +10,8 @@ module.exports = function(sequelize, DataTypes) { is_root: DataTypes.BOOLEAN, starred: DataTypes.BOOLEAN, parent_id: DataTypes.INTEGER, - user_id: DataTypes.INTEGER + user_id: DataTypes.INTEGER, + is_deleted: DataTypes.BOOLEAN }, { classMethods: { associate: function(models) { diff --git a/public/javascripts/client.js b/public/javascripts/client.js new file mode 100644 index 0000000..637660a --- /dev/null +++ b/public/javascripts/client.js @@ -0,0 +1,29 @@ +$( document ).ready( () => { + const modal = document.querySelector( '.modal' ) + + document.querySelector( '.export-link' ).onclick = event => { + event.preventDefault() + + modal.style.display = 'block' + } + + document.querySelector( '.preview-window--footer__close-link' ).onclick = event => { + event.preventDefault() + + modal.style.display = 'none' + } + + document.getElementById( 'plain-text-preview' ).onclick = event => { + $( '#preview-text-text.item-page.text__tree.hidden' ).removeClass( 'hidden' ).addClass( 'show' ) + $( '#preview-text.item-page.html__tree.show' ).removeClass( 'show' ).addClass( 'hidden' ) + $( '.plain-text-download' ).removeClass( 'hidden' ).addClass( 'show' ) + $( '.html-text-download' ).removeClass( 'show' ).addClass( 'hidden' ) + } + + document.getElementById( 'formatted-text-preview' ).onclick = event => { + $( '#preview-text.item-page.html__tree.hidden' ).removeClass( 'hidden' ).addClass( 'show' ) + $( '#preview-text-text.item-page.text__tree.show' ).removeClass( 'show' ).addClass( 'hidden' ) + $( '.html-text-download' ).removeClass( 'hidden' ).addClass( 'show' ) + $( '.plain-text-download' ).removeClass( 'show' ).addClass( 'hidden' ) + } +}) diff --git a/public/javascripts/items.js b/public/javascripts/items.js index 2c69885..1ee21e5 100644 --- a/public/javascripts/items.js +++ b/public/javascripts/items.js @@ -18,6 +18,8 @@ const titleEdited = event => { const elementToHide = $( event.target ) const id = elementToHide.data( 'id' ) const elementToShow = $( selector( 'item__title', id, 'span' ) ) + console.log('element', elementToHide); + if( event.keyCode === RETURN_KEY ) { let updatedTitle = elementToHide[0].value @@ -50,7 +52,7 @@ const descriptionEdited = event => { const completedClicked = event => { const element = $( event.target ) - const id = element.data( 'id' ) + const id = element.parent().data( 'id' ) const completed = ! element.data( 'completed' ) fetch( `/items/${id}`, params({ completed: completed } ) ) @@ -128,14 +130,24 @@ const completedClicked = event => { ) } + const deleteItem = event => { + const elementToHide = $( event.target ).closest( '.item' ) + const id = elementToHide.data( 'id' ) + const elementToDelete = $( `.title__data[data-id='${id}']` ) + + fetch( `/items/${id}`, fetchParams( 'DELETE' )) + .then( result => $( elementToHide[0] ).addClass( 'item--hidden' )) + } + $(document).ready( () => { $( '.item__edit-title' ).keypress( titleEdited ) $( '.item__title > span' ).click( clickToUpdate( 'item__title' )) $( '.item__edit-description' ).keypress( descriptionEdited ) $( '.item__description > span' ).click( clickToUpdate( 'item__description' )) - $( '.item__toggle' ).click( completedClicked ) + $( '.item__menu > ul > li:first-child' ).click( completedClicked ) $( '.dropdown__toggle' ).click( dropdownToggle ) $( '.star' ).click( starredToggle ) + $( '.delete' ).click( deleteItem ) getFilterStatus() getCheckedStatus() }) diff --git a/public/javascripts/task-dropdown.js b/public/javascripts/task-dropdown.js new file mode 100644 index 0000000..ee3f115 --- /dev/null +++ b/public/javascripts/task-dropdown.js @@ -0,0 +1,12 @@ +const taskMenuTriggers = document.querySelectorAll( '.item__toggle' ) + +taskMenuTriggers.forEach( trigger => { + trigger.addEventListener( 'mouseenter', event => { + event.target.children[ 0 ].style.display = 'block' + }) + + trigger.addEventListener( 'mouseleave', event => { + event.target.children[ 0 ].style.display = 'none' + }) +}) + diff --git a/public/stylesheets/authenticated.css b/public/stylesheets/authenticated.css index 0f1d3a4..dda89b4 100644 --- a/public/stylesheets/authenticated.css +++ b/public/stylesheets/authenticated.css @@ -69,6 +69,7 @@ } .item__toggle { + position: relative; display: inline; font-size: 14px; padding: 0 6px; diff --git a/public/stylesheets/exportmodal.css b/public/stylesheets/exportmodal.css new file mode 100644 index 0000000..6fd1837 --- /dev/null +++ b/public/stylesheets/exportmodal.css @@ -0,0 +1,113 @@ +.modal { + display: none; + font-family: 'Open Sans', Arial; + background-color: white; + width: 65%; + font-size: 15px; + height: 500px; + position: absolute; + top: 20%; + left: 20%; + bottom: 20%; + right: 20%; + overflow: auto; + z-index: 101; + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); + outline: .5px #888 solid; +} + +a.preview-window--footer__download-link, a.preview-window--footer__download-link:visited { + font-size: 15px; + color: blue; +} + +.hidden { + display: none; +} + +.show { + display: block; + font-size: 14px; +} + +.preview-window--header { + display: flex; + padding-left: 15px; + padding-right: 15px; + width: 100%; + color: #4F4F4F; + text-align: left; + text-shadow: 0 1px 0 rgba(255,255,255,0.5); + flex-direction: row; + background: rgba(226,226,226,1); + background: -moz-linear-gradient(left, rgba(226,226,226,1) 0%, rgba(221,221,221,1) 41%, rgba(219,219,219,1) 58%, rgba(244,244,244,1) 87%, rgba(254,254,254,1) 98%); + background: -webkit-gradient(left top, right top, color-stop(0%, rgba(226,226,226,1)), color-stop(41%, rgba(221,221,221,1)), color-stop(58%, rgba(219,219,219,1)), color-stop(87%, rgba(244,244,244,1)), color-stop(98%, rgba(254,254,254,1))); + background: -webkit-linear-gradient(left, rgba(226,226,226,1) 0%, rgba(221,221,221,1) 41%, rgba(219,219,219,1) 58%, rgba(244,244,244,1) 87%, rgba(254,254,254,1) 98%); + background: -o-linear-gradient(left, rgba(226,226,226,1) 0%, rgba(221,221,221,1) 41%, rgba(219,219,219,1) 58%, rgba(244,244,244,1) 87%, rgba(254,254,254,1) 98%); + background: -ms-linear-gradient(left, rgba(226,226,226,1) 0%, rgba(221,221,221,1) 41%, rgba(219,219,219,1) 58%, rgba(244,244,244,1) 87%, rgba(254,254,254,1) 98%); + background: linear-gradient(to right, rgba(226,226,226,1) 0%, rgba(221,221,221,1) 41%, rgba(219,219,219,1) 58%, rgba(244,244,244,1) 87%, rgba(254,254,254,1) 98%); +} + +.preview-window--body { + outline: .5px #888 solid; + margin-left: 15px; + margin-right: 15px; + margin-top: 15px; + padding-top: 15px; + padding-left: 15px; + padding-right: 15px; + overflow-y: auto; + justify-content: center; + overflow: auto; + height: 200px; + width: auto; +} + +.preview-window--body__item { + margin-right: 14px; + margin-left: 14px; + padding: 15px; + background-color: #EEE; + align-content: center; + justify-content: space-between; + border: .25px #B6B6B6 solid; +} + +.preview-window--footer { + color: #666; + font-size: 15px; + display: flex; + flex-direction: column; + align-content: center; + align-items: center; + justify-content: center; +} + +p.preview-window--footer__item { + margin: 5px; +} + +.preview-window--footer__separator { + border-top: .5px #888 solid; + margin-top: 10px; + display: flex; + flex-direction: column; + align-content: center; + align-items: center; + justify-content: center; +} + +.preview-window--footer__close-link { + position: relative; + margin-top: 10px; + margin-bottom: 5px; + padding: 5px; + font-size: 13px; + color: white; + width: 25%; + border: 1px solid #111; +} + +.preview-window--footer__close-link:hover { + background-color: #888; +} diff --git a/public/stylesheets/item-menu.css b/public/stylesheets/item-menu.css new file mode 100644 index 0000000..e4e0488 --- /dev/null +++ b/public/stylesheets/item-menu.css @@ -0,0 +1,50 @@ +.item__menu { + display: none; + position: absolute; + left: 50%; + transform: translateX(-50%); + margin-top: 14px; + background-color: #e8e8e8; + padding: 10px 5px; + width: 80px; + margin-bottom: 10px; + border-radius: 4px; + border: 1px solid #bbb ; + z-index: 8; +} + +.item__menu::before { + content: ''; + position: absolute; + top: -15px; + left: 50%; + transform: translateX(-50%); + border-bottom: 15px solid hsl(0, 0%, 91%); + border-left: 15px solid transparent; + border-right: 15px solid transparent; +} + +.item__menu ul { + padding: 0; + margin: 0; + list-style: none; +} + +.item__menu li { + border-top: 1px solid #bbb; + padding: 5px 0; +} + +.item__menu li:hover { + text-decoration: underline; + cursor: pointer; +} + +.item__menu li:first-child { + border-top: none; + padding-top: 0; +} + +.item__menu li:last-child { + padding-bottom: 0; +} diff --git a/public/stylesheets/navbar.css b/public/stylesheets/navbar.css index c94fdec..fa16c9b 100644 --- a/public/stylesheets/navbar.css +++ b/public/stylesheets/navbar.css @@ -148,7 +148,8 @@ text-decoration: none; color: #fff; width: 100%; - padding: 6px 0; + padding-top: 10px; + padding-bottom: 10px; display: block; } @@ -157,10 +158,6 @@ cursor: pointer; } -.dropdown__item--label { - margin-left: 10px; -} - .dropdown__toggle--open { border-bottom: none; background-color: #666; diff --git a/public/stylesheets/style.css b/public/stylesheets/style.css index 992048e..5b72fa5 100644 --- a/public/stylesheets/style.css +++ b/public/stylesheets/style.css @@ -1,4 +1,4 @@ -body{ +body { min-width: 100%; top: 0; left: 0; diff --git a/routes/items.js b/routes/items.js index cb4eab8..e7714e0 100644 --- a/routes/items.js +++ b/routes/items.js @@ -9,7 +9,6 @@ const buildStarredItemArray = require( './items/build_starred_item_array' ) router.get( '/', ( request, response ) => { const { Item } = request.app.get( 'models' ) - const { user, query } = request buildFilteredItemTree( Item, user, query ) @@ -25,6 +24,17 @@ router.get( '/starred', ( request, response ) => { .then( data => response.json( { data } ) ) }) +router.get( '/download/:type', ( request, response ) => { + const { Item } = request.app.get( 'models' ) + + const { user, query } = request + + response.set( 'Content-Type', 'text/plain' ) + buildFilteredItemTree( Item, user, query ) + .then( generateBreadcrumbs ) + .then( respondWithItems( user, data => response.render( `items/${request.params.type}`, data ))) +}) + router.get( '/:item_id', ( request, response ) => { const { Item } = request.app.get( 'models' ) @@ -64,4 +74,19 @@ router.post( '/:id', ( request, response ) => { ) }) +router.delete( '/:id', ( request, response ) => { + const { Item, Audit } = request.app.get( 'models' ) + const id = parseInt( request.params.id ) + const user_id = parseInt( request.user.id ) + + Item.findOne({ where: { id, user_id } }) + .then( item => { + item.update({ is_deleted: true }) + + return item + }) + .then( createAuditEntries( Item, Audit, user_id ) ) + .then( result => response.status( 200 ).send({}) ) +}) + module.exports = router diff --git a/routes/items/item_response.js b/routes/items/item_response.js index 5655061..62df320 100644 --- a/routes/items/item_response.js +++ b/routes/items/item_response.js @@ -1,6 +1,6 @@ const { pruneTree } = require( './tree_creation' ) -const FETCH_ATTRIBUTES = [ 'title', 'description', 'completed', 'starred', 'is_root', 'parent_id', 'id' ] +const FETCH_ATTRIBUTES = [ 'title', 'description', 'completed', 'starred', 'is_root', 'parent_id', 'id', 'is_deleted' ] const createRootItem = Item => user => { return Item.create({ @@ -8,12 +8,14 @@ const createRootItem = Item => user => { parent_id: 0, title: 'Home', description: 'Welcome to Floworky', - user_id: user.id + user_id: user.id, + is_deleted: false }).then( result => user ) } +const filterStatus = user_id => ( { user_id, is_deleted: false } ) const allItemsQuery = user_id => ( - { order: [['createdAt', 'ASC']], where: { user_id }, FETCH_ATTRIBUTES } + { order: [['createdAt', 'ASC']], where: filterStatus( user_id ), FETCH_ATTRIBUTES } ) const filterClause = ( query, user_id ) => @@ -23,7 +25,7 @@ const filterClause = ( query, user_id ) => whereSearch( query ), whereCompleted( query ), whereStarred( query ), - { user_id } + filterStatus( user_id ) ) }) diff --git a/src/item_node.js b/src/item_node.js index 94fc1c9..f733786 100644 --- a/src/item_node.js +++ b/src/item_node.js @@ -1,6 +1,6 @@ class ItemNode { constructor( item ) { - const { id, parent_id, title, description, completed, starred, is_root } = item + const { id, parent_id, title, description, completed, starred, is_root, is_deleted } = item this.id = id this.parent_id = parent_id @@ -9,7 +9,7 @@ class ItemNode { this.completed = completed this.starred = starred this.is_root = is_root - + this.is_deleted = is_deleted this.children = [] } diff --git a/views/items/html.pug b/views/items/html.pug new file mode 100644 index 0000000..8b46a67 --- /dev/null +++ b/views/items/html.pug @@ -0,0 +1,3 @@ +include mixins.pug + ++display( tree, 0 ) \ No newline at end of file diff --git a/views/items/index.pug b/views/items/index.pug index a8b531e..4c10055 100644 --- a/views/items/index.pug +++ b/views/items/index.pug @@ -1,22 +1,10 @@ extends ../layouts/authenticated-layout -mixin display( nodes, depth ) - each entry in nodes - .item(data-id=entry.id) - .item__heading(data-id=entry.id, data-completed=entry.completed) - .item__toggle(aria-hidden='true', data-id=entry.id, data-completed=entry.completed) - |● - .item__title(data-id=entry.id, data-completed=entry.completed, class={completed: entry.completed}) - span(data-id=entry.id)=entry.title - input.item__edit-title.item--hidden(data-id=entry.id, type='text', name='edit-title', value=entry.title) - .item__description(data-id=entry.id) - span(data-id=entry.id)=entry.description - input.item__edit-description.item--hidden(data-id=entry.id, type='text', name='edit-description', value=entry.description) - .item__children - +display( entry.children, depth + 1 ) +include mixins.pug block content - + .modal + include preview-export.pug div.main-page .work-page .work-page__main diff --git a/views/items/item-menu.pug b/views/items/item-menu.pug new file mode 100644 index 0000000..adddf6a --- /dev/null +++ b/views/items/item-menu.pug @@ -0,0 +1,7 @@ +ul(data-id=entry.id) + li Complete + li Add Note + li Share + li Export + li Duplicate + li.delete Delete diff --git a/views/items/mixins.pug b/views/items/mixins.pug new file mode 100644 index 0000000..5ed856d --- /dev/null +++ b/views/items/mixins.pug @@ -0,0 +1,26 @@ +mixin textEntry( entry, depth ) + | !{`${" ".repeat( depth * 2 )}- ${entry.completed ? '[COMPLETED] ' : ''}${entry.title}\n`} + if( entry.description ) + | !{`${" ".repeat( depth * 2 + 2 )}"${entry.description}"`} + +mixin displayText( nodes, depth ) + each entry in nodes + +textEntry( entry, depth ) + +displayText( entry.children, depth + 1 ) + +mixin display( nodes, depth ) + each entry in nodes + .item(data-id=entry.id) + .item__heading(data-id=entry.id, data-completed=entry.completed) + .item__toggle(aria-hidden='true', data-id=entry.id, data-completed=entry.completed) + |● + .item__menu + include item-menu.pug + .item__title(data-id=entry.id, data-completed=entry.completed, class={completed: entry.completed}) + span.title__data(data-id=entry.id)=entry.title + input.item__edit-title.item--hidden(data-id=entry.id, type='text', name='edit-title', value=entry.title) + .item__description(data-id=entry.id) + span(data-id=entry.id)=entry.description + input.item__edit-description.item--hidden(data-id=entry.id, type='text', name='edit-description', value=entry.description) + .item__children + +display( entry.children, depth + 1 ) diff --git a/views/items/plain_text.pug b/views/items/plain_text.pug new file mode 100644 index 0000000..85432ef --- /dev/null +++ b/views/items/plain_text.pug @@ -0,0 +1,3 @@ +include mixins.pug + ++displayText( tree, 0 ) \ No newline at end of file diff --git a/views/items/preview-export.pug b/views/items/preview-export.pug new file mode 100644 index 0000000..3b8a182 --- /dev/null +++ b/views/items/preview-export.pug @@ -0,0 +1,26 @@ +modal + .preview-window + .preview-window--header + h3.preview-window--header__item Export List + + .preview-window--body + #preview-text.item-page.html__tree.show + +display( tree, 0 ) + #preview-text-text.item-page.text__tree.hidden + pre + +displayText( tree, 0 ) + .preview-window--body__item + form + label Formatted + input#formatted-text-preview.preview-window--body__item( type='radio' checked name="selected-preview") + label Plain Text + input#plain-text-preview.preview-window--body__item( type='radio' name="selected-preview") + + .preview-window--footer + p.preview-window--footer__item Press ⌘ + C to copy your list. You can paste it anywhere. + .plain-text-download.show + a.preview-window--footer__download-link(href='/items/download/plain_text' rel='external' download='floworky-export.txt') (Or click to download) + .html-text-download.hidden + a.preview-window--footer__download-link(href='/items/download/html' rel='external' download='floworky-export.html') (Or click to download) + .preview-window--footer__separator + button.preview-window--footer__close-link Close \ No newline at end of file diff --git a/views/layouts/authenticated-layout.pug b/views/layouts/authenticated-layout.pug index 967fcf0..9d83e5e 100644 --- a/views/layouts/authenticated-layout.pug +++ b/views/layouts/authenticated-layout.pug @@ -13,7 +13,9 @@ html link(rel='stylesheet', href='/stylesheets/navbar.css') link(rel='stylesheet', href='/stylesheets/authenticated.css') link(rel='stylesheet', href='/stylesheets/weekly-review.css') - + link(rel='stylesheet', href='/stylesheets/exportmodal.css') + link(rel='stylesheet', href='/stylesheets/item-menu.css') + body nav.navbar .navbar__header @@ -39,6 +41,8 @@ html li.dropdown__item a.dropdown__item--link .dropdown__item--label#weekly-summary Weekly Summary + li.dropdown__item + a.dropdown__item--link.export-link Export All li.dropdown__item a.dropdown__item--link(href='/accounts/logout') .dropdown__item--label Log Out @@ -46,7 +50,7 @@ html .container block content - + include ../accounts/weekly-report.pug script(src='https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js') @@ -54,3 +58,5 @@ html script(src='/javascripts/items.js') script(src='/javascripts/starred.js') script(src='/javascripts/weekly-review.js') + script(src='/javascripts/client.js') + script(src='/javascripts/task-dropdown.js') diff --git a/views/layouts/layout.pug b/views/layouts/layout.pug index ef7f221..6c4d3cc 100644 --- a/views/layouts/layout.pug +++ b/views/layouts/layout.pug @@ -12,7 +12,6 @@ html link(href='https://fonts.googleapis.com/css?family=Roboto:300', rel='stylesheet') link(rel='stylesheet', href='/stylesheets/style.css') link(rel='stylesheet', href='/stylesheets/login.css') - body .login-page nav